const $ = id => document.getElementById(id); // ── Food categories ──────────────────────────────────────────────────────── const FOOD_CATEGORIES = new Set(['Restaurant', 'Bar', 'Cafe', 'Bakery', 'Market']); // ── State ────────────────────────────────────────────────────────────────── let currentJobId = null; let userLat = null; let userLng = null; let postCount = 100; // updated when a file is selected let _geolocationRequested = false; // ── Top-level navigation ─────────────────────────────────────────────────── function showTopTab(name) { document.querySelectorAll('.top-panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.top-tab-btn[data-top-tab]').forEach(b => b.classList.toggle('active', b.dataset.topTab === name) ); const panel = $('top-panel-' + name); if (panel) panel.classList.add('active'); } // Wire top tab buttons document.querySelectorAll('.top-tab-btn[data-top-tab]').forEach(btn => { btn.addEventListener('click', () => showTopTab(btn.dataset.topTab)); }); // ── Sub-tab bar (inside Explore) ─────────────────────────────────────────── function showSubTab(name) { document.querySelectorAll('.sub-panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.sub-tab-btn[data-sub-tab]').forEach(b => b.classList.toggle('active', b.dataset.subTab === name) ); const panel = $('panel-' + name); if (panel) panel.classList.add('active'); if (name === 'roulette') { if (!_geolocationRequested) _requestGeolocation(); // Auto-spin on first visit (card not yet shown) if (currentJobId && $('pick-card').style.display === 'none') { setTimeout(() => $('spin-btn').click(), 120); } } if (name === 'browse' && _map) setTimeout(() => _map.invalidateSize(), 60); } function showSubTabBar(jobId) { currentJobId = jobId; $('import-card').style.display = 'none'; const rc = $('restore-card'); if (rc) rc.style.display = 'none'; // a library is active — restore prompt is moot $('sub-tab-bar').style.display = 'block'; $('download-bar').style.display = 'block'; } function updateTabBadges() { const unvisited = allRows.filter(r => !r.status || r.status === 'unvisited' || r.status === 'want_to_go' ).length; const total = allRows.length; const br = $('badge-roulette'); const bb = $('badge-browse'); if (br) { br.textContent = unvisited ? ` ${unvisited}` : ''; br.style.display = unvisited ? '' : 'none'; } if (bb) { bb.textContent = total ? ` ${total}` : ''; bb.style.display = total ? '' : 'none'; } // Stat strip const strip = $('tab-stat-strip'); if (strip && total) { const pinned = allRows.filter(r => r.geocoded).length; const costPart = lastJobCost ? ` · ~$${lastJobCost}` : ''; strip.style.display = 'block'; strip.innerHTML = `${total} places · ${pinned} pinned${costPart}`; } } function _requestGeolocation() { _geolocationRequested = true; if (navigator.geolocation) { navigator.geolocation.getCurrentPosition(pos => { userLat = pos.coords.latitude; userLng = pos.coords.longitude; $('radius-row').style.display = 'flex'; }); } } // Sub-tab button clicks document.querySelectorAll('.sub-tab-btn[data-sub-tab]').forEach(btn => { btn.addEventListener('click', () => showSubTab(btn.dataset.subTab)); }); $('new-extract-btn').addEventListener('click', () => showTopTab('extract')); // Nudge toast let _nudgeTimer = null; function showNudge() { const toast = $('nudge-toast'); toast.style.display = 'flex'; _nudgeTimer = setTimeout(() => { toast.style.display = 'none'; }, 6000); } $('nudge-dismiss').addEventListener('click', () => { clearTimeout(_nudgeTimer); $('nudge-toast').style.display = 'none'; }); // ── Import card ──────────────────────────────────────────────────────────── const importDropZone = $('import-drop-zone'); const importFileInput = $('import-file-input'); importDropZone.addEventListener('dragover', e => { e.preventDefault(); importDropZone.classList.add('drag-over'); }); importDropZone.addEventListener('dragleave', () => importDropZone.classList.remove('drag-over')); importDropZone.addEventListener('drop', e => { e.preventDefault(); importDropZone.classList.remove('drag-over'); if (e.dataTransfer.files[0]) handleImportFile(e.dataTransfer.files[0]); }); importFileInput.addEventListener('change', () => { if (importFileInput.files[0]) handleImportFile(importFileInput.files[0]); }); // POST a places file (KML/CSV) to /import and wire the Explore UI to the new // job. Shared by manual upload (handleImportFile) and "restore last library" // (maybeShowRestore). When a cached library exists (and forceReplace is off), // the file is merged into it server-side (keep-existing-wins). Returns the new // job_id or throws. async function importPlacesFile(file, statusEl, { forceReplace = false } = {}) { const fd = new FormData(); fd.append('file', file); const lib = forceReplace ? null : _readSavedLibrary(); if (lib && lib.csv) fd.append('merge_into', lib.csv); const res = await fetch('/import', { method: 'POST', body: fd }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); throw new Error(err.error || res.statusText); } const data = await res.json(); if (statusEl) statusEl.textContent = ''; showTopTab('explore'); showSubTabBar(data.job_id); $('download-btn').onclick = () => { window.location = '/download/' + data.job_id; }; $('download-geojson-btn').onclick = () => { window.location = '/download/' + data.job_id + '/geojson'; }; $('download-csv-btn').onclick = () => { window.location = '/download/' + data.job_id + '/csv'; }; showSubTab('browse'); await loadResults(data.job_id); if (data.merged) { // Tag the freshly-added rows so Browse can badge them and float them to top. if (data.added_urls && data.added_urls.length) { const fresh = new Set(data.added_urls); allRows.forEach(r => { if (fresh.has(r.instagram_url)) r._isNew = true; }); applyFilters(); } _showMergeBanner(data, file); } if (_clientGeocode) geocodeClientSide(); return data.job_id; } // After a merge import, tell the user what changed. If nothing in the file // matched their library (no_overlap), it may be the wrong file — offer Replace. function _showMergeBanner(data, file) { const banner = $('new-banner'), txt = $('new-banner-text'); if (!banner || !txt) return; const n = data.added, kept = data.kept || 0; if (data.no_overlap && n > 0) { txt.innerHTML = `Added ${n} place${n === 1 ? '' : 's'} — none matched your existing library. ` + `Replace instead?`; } else if (n > 0) { const keptNote = kept ? ` · ${kept} already in your library` : ''; txt.textContent = `${n} new place${n === 1 ? '' : 's'} added${keptNote}`; } else { txt.textContent = 'No new places — everything in that file was already in your library'; } banner.style.display = 'block'; const auto = setTimeout(() => { banner.style.display = 'none'; }, 9000); $('new-banner-dismiss').onclick = () => { clearTimeout(auto); banner.style.display = 'none'; }; const replace = $('merge-replace-link'); if (replace) replace.onclick = (e) => { e.preventDefault(); clearTimeout(auto); banner.style.display = 'none'; importPlacesFile(file, null, { forceReplace: true }); }; } async function handleImportFile(f) { const ext = f.name.split('.').pop().toLowerCase(); const status = $('import-status'); if (!['kml', 'csv'].includes(ext)) { status.textContent = '⚠ Only .kml or .csv files are supported.'; status.classList.add('error'); return; } $('import-file-chosen').textContent = '📄 ' + f.name; $('import-file-chosen').style.display = 'block'; status.textContent = 'Importing…'; status.classList.remove('error'); const modeEl = document.querySelector('input[name="import-mode"]:checked'); const forceReplace = modeEl ? modeEl.value === 'replace' : false; try { await importPlacesFile(f, status, { forceReplace }); } catch (err) { status.textContent = '⚠ ' + err.message; status.classList.add('error'); } } // Show the Merge/Replace control on the import card only when a cached library // exists (otherwise an import is always a fresh start — nothing to merge into). function _syncImportMode() { const el = $('import-mode'); if (!el) return; const lib = _readSavedLibrary(); el.style.display = (lib && lib.csv) ? 'block' : 'none'; } // ── Client-side library persistence (localStorage) ───────────────────────── // Cache the canonical CSV so a returning visitor restores their library without // re-uploading or re-extracting. Reconstruction-cache only: restore replays the // CSV through /import (importPlacesFile), so status/coords/edits round-trip // losslessly — see tests/test_persistence.py. Stored only on this device; never // uploaded. Works in both deployment modes (rarely fires on Docker, where the // server job usually still exists). const LIB_KEY = 'savedLibrary'; let _libQuotaWarned = false; let _cacheTimer = null; async function cacheLibrary(jobId) { if (!jobId) return; try { const res = await fetch('/download/' + jobId + '/csv'); if (!res.ok) return; const csv = await res.text(); localStorage.setItem(LIB_KEY, JSON.stringify({ csv, savedAt: new Date().toISOString(), count: allRows.length, version: 1, })); } catch (e) { if (e && e.name === 'QuotaExceededError') _warnLibQuota(); } } // Debounced re-cache after edits (status, fields, coords) keeps the stored copy // in sync with what the user sees. function cacheLibrarySoon() { clearTimeout(_cacheTimer); _cacheTimer = setTimeout(() => { _cacheTimer = null; cacheLibrary(currentJobId); }, 1500); } // Flush a pending debounced cache before the tab is backgrounded or closed, so // an edit made in the last ~1.5s isn't lost if the user leaves immediately. // visibilitychange→hidden is the reliable lifecycle signal (esp. on mobile, // where beforeunload/unload don't fire); the page is still alive enough here to // complete the fetch. pagehide covers bfcache/navigation. function flushCache() { if (!_cacheTimer) return; clearTimeout(_cacheTimer); _cacheTimer = null; cacheLibrary(currentJobId); } document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushCache(); }); window.addEventListener('pagehide', flushCache); function _warnLibQuota() { if (_libQuotaWarned) return; _libQuotaWarned = true; const bar = $('download-bar'); if (!bar) return; const note = document.createElement('div'); note.className = 'lib-quota-note'; note.textContent = '⚠ Library too large to save on this device — use Download to keep a backup.'; bar.appendChild(note); } function _libRelTime(iso) { const then = new Date(iso); const days = Math.floor((Date.now() - then.getTime()) / 86400000); if (days <= 0) return 'today'; if (days === 1) return 'yesterday'; if (days < 30) return `${days} days ago`; return then.toLocaleDateString(); } function _readSavedLibrary() { try { return JSON.parse(localStorage.getItem(LIB_KEY) || 'null'); } catch { return null; } } // On a fresh visit with no live job, offer to restore the cached library. function maybeShowRestore() { if (currentJobId) return; const lib = _readSavedLibrary(); const card = $('restore-card'); if (!lib || !lib.csv || !card) return; $('restore-sub').textContent = `${lib.count} place${lib.count === 1 ? '' : 's'} · saved ${_libRelTime(lib.savedAt)}`; card.style.display = 'block'; } if ($('restore-btn')) { $('restore-btn').addEventListener('click', async () => { const lib = _readSavedLibrary(); if (!lib || !lib.csv) { $('restore-card').style.display = 'none'; return; } const btn = $('restore-btn'); btn.disabled = true; btn.textContent = 'Restoring…'; const file = new File([lib.csv], 'places_full.csv', { type: 'text/csv' }); try { // Full restore, not an incremental add — don't merge the library with itself. await importPlacesFile(file, null, { forceReplace: true }); $('restore-card').style.display = 'none'; } catch { btn.disabled = false; btn.textContent = 'Restore'; $('restore-sub').textContent = '⚠ Could not restore — try uploading your CSV backup.'; } }); $('restore-forget-btn').addEventListener('click', () => { localStorage.removeItem(LIB_KEY); $('restore-card').style.display = 'none'; }); } // ── Cost estimation ──────────────────────────────────────────────────────── // Mirrors extract.py: _PROMPT_BASE_TOKENS=180, avg caption ~70 tok, _OUTPUT_AVG_TOKENS=70 const _PROMPT_TOK = 250; const _OUTPUT_TOK = 70; function _estimateCost(inputPer1M, outputPer1M, n) { return (_PROMPT_TOK * inputPer1M + _OUTPUT_TOK * outputPer1M) / 1_000_000 * n; } function updateCostDisplays() { document.querySelectorAll('.model-cost-estimate').forEach(el => { const inp = parseFloat(el.dataset.input || '0'); const out = parseFloat(el.dataset.output || '0'); const cost = _estimateCost(inp, out, postCount); el.textContent = `~$${cost.toFixed(2)} for ${postCount} posts`; }); } // Show default estimates immediately (before any file is selected) updateCostDisplays(); // ── Drop zone ────────────────────────────────────────────────────────────── const dropZone = $('drop-zone'); const fileInput = $('file-input'); const runBtn = $('run-btn'); const exportGuide = $('export-guide'); dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]); }); fileInput.addEventListener('change', () => { if (fileInput.files[0]) handleFile(fileInput.files[0]); }); function _fileError(preview, msg) { preview.textContent = '⚠ ' + msg; preview.className = 'post-count-preview error'; dropZone.classList.remove('valid'); dropZone.classList.add('invalid'); runBtn.disabled = true; runBtn.textContent = 'Choose a file to continue'; } let _pendingJsonBlob = null; let _urlMode = false; function handleFile(f) { _urlMode = false; const urlInput = $('url-input'); if (urlInput) { urlInput.value = ''; urlInput.classList.remove('valid'); } dropZone.classList.remove('valid', 'invalid'); $('post-count-preview').className = 'post-count-preview'; $('post-count-preview').style.display = 'none'; $('file-chosen').textContent = '📄 ' + f.name; $('file-chosen').style.display = 'block'; runBtn.disabled = true; runBtn.textContent = 'Choose a file to continue'; _pendingJsonBlob = null; if (f.name.toLowerCase().endsWith('.zip')) { _handleZip(f); } else { const reader = new FileReader(); reader.onload = e => _validateJson(e.target.result, $('post-count-preview')); reader.readAsText(f); } } async function _handleZip(f) { const preview = $('post-count-preview'); preview.style.display = 'block'; preview.textContent = 'Reading zip…'; preview.className = 'post-count-preview'; let zip; try { zip = await JSZip.loadAsync(await f.arrayBuffer()); } catch { _fileError(preview, 'Could not read zip file. Make sure it is the unmodified Instagram export.'); return; } const match = Object.keys(zip.files).find(name => /saved_posts\.json$/.test(name)); if (!match) { _fileError(preview, 'saved_posts.json not found in this zip. Make sure you exported Saved posts in JSON format.'); return; } let text; try { text = await zip.files[match].async('text'); } catch { _fileError(preview, 'Failed to extract saved_posts.json from zip.'); return; } _pendingJsonBlob = new Blob([text], { type: 'application/json' }); _validateJson(text, preview); } function _validateJson(text, preview) { preview.style.display = 'block'; let posts; try { posts = JSON.parse(text); } catch { _fileError(preview, "That doesn't look like a JSON file. Make sure you export in JSON format (not HTML) from Instagram."); return; } if (!Array.isArray(posts)) { _fileError(preview, "Wrong file — this JSON doesn't look like a saved_posts.json export."); return; } if (posts.length === 0) { _fileError(preview, "This file has no saved posts."); return; } const first = posts[0]; if (!first.label_values && !first.timestamp) { _fileError(preview, "Wrong file — expected your_instagram_activity/saved/saved_posts.json."); return; } postCount = posts.length; preview.textContent = `Found ${postCount} saved posts`; preview.className = 'post-count-preview success'; dropZone.classList.remove('invalid'); dropZone.classList.add('valid'); exportGuide.open = false; updateCostDisplays(); runBtn.disabled = false; runBtn.textContent = 'Extract now →'; $('chatbot-prepare-btn').disabled = false; } // ── URL input mode ───────────────────────────────────────────────────────── const _hasServerKey = document.body.dataset.hasServerKey === 'true'; function _anthropicKeyMissing() { return activeProvider === 'anthropic' && !_hasServerKey && !($('anthropic-api-key').value || '').trim(); } function _syncUrlModeButtons(looksLikeIG) { if (activeProvider === 'chatbot') { runBtn.style.display = 'none'; $('chatbot-prepare-btn').style.display = 'block'; $('chatbot-prepare-btn').disabled = !looksLikeIG; $('chatbot-prepare-btn').textContent = looksLikeIG ? 'Prepare extraction file →' : 'Enter an Instagram URL to continue'; } else { runBtn.style.display = 'block'; $('chatbot-prepare-btn').style.display = 'none'; const needsKey = looksLikeIG && _anthropicKeyMissing(); runBtn.disabled = !looksLikeIG || needsKey; runBtn.textContent = !looksLikeIG ? 'Enter an Instagram URL to continue' : needsKey ? 'Enter API key above to continue' : 'Extract now →'; } } const urlInput = $('url-input'); urlInput.addEventListener('input', () => { const val = urlInput.value.trim(); const looksLikeIG = /instagram\.com/.test(val); if (val) { _urlMode = true; dropZone.classList.remove('valid', 'invalid'); $('file-chosen').style.display = 'none'; $('post-count-preview').style.display = 'none'; _pendingJsonBlob = null; fileInput.value = ''; urlInput.classList.toggle('valid', looksLikeIG); _syncUrlModeButtons(looksLikeIG); } else { _urlMode = false; urlInput.classList.remove('valid'); setProvider(activeProvider); const hasFile = !!fileInput.files[0] || !!_pendingJsonBlob; runBtn.disabled = !hasFile; if (!hasFile) runBtn.textContent = 'Choose a file to continue'; } }); $('anthropic-api-key').addEventListener('input', () => { if (_urlMode) _syncUrlModeButtons(/instagram\.com/.test(urlInput.value.trim())); }); async function _submitUrl() { const url = urlInput.value.trim(); if (!url) return; runBtn.innerHTML = 'Starting…'; runBtn.disabled = true; const fd = new FormData(); fd.append('url', url); fd.append('provider', activeProvider === 'chatbot' ? 'anthropic' : activeProvider); fd.append('model', selectedModel()); fd.append('ollama_model', selectedOllamaModel()); fd.append('api_key', ($('anthropic-api-key').value || '').trim()); let res; try { res = await fetch('/extract-url', { method: 'POST', body: fd }); if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || res.statusText); } } catch (err) { runBtn.innerHTML = err.message; runBtn.disabled = false; return; } const { job_id } = await res.json(); currentJobId = job_id; $('extract-step-label').textContent = 'Extract place from Instagram URL'; $('upload-card').style.display = 'none'; $('model-card').style.display = 'none'; $('run-btn-wrap').style.display = 'none'; $('progress-section').style.display = 'block'; listenProgress(job_id); } async function _urlChatbotPrepare() { const url = urlInput.value.trim(); if (!url) return; const prepBtn = $('chatbot-prepare-btn'); prepBtn.innerHTML = 'Fetching post…'; prepBtn.disabled = true; const fd = new FormData(); fd.append('url', url); let res; try { res = await fetch('/url-chatbot-prepare', { method: 'POST', body: fd }); if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || res.statusText); } } catch (err) { prepBtn.innerHTML = 'Error: ' + err.message; prepBtn.disabled = false; return; } const data = await res.json(); _chatbotJobId = data.job_id; _chatbotPromptText = data.prompt; _chatbotChunkCount = 1; _chatbotResponses = []; const posts = data.export_posts; const dl = $('chatbot-downloads'); dl.innerHTML = ''; const blob = new Blob([JSON.stringify(posts, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.className = 'btn btn-outline'; a.style.textDecoration = 'none'; a.download = 'instagram_place.json'; a.href = URL.createObjectURL(blob); a.textContent = '⬇ Download post JSON'; dl.appendChild(a); $('chatbot-chunk-note').style.display = 'none'; $('chatbot-add-response-btn').style.display = 'none'; $('chatbot-process-btn').disabled = true; $('chatbot-response-area').value = ''; $('chatbot-response-file').value = ''; $('chatbot-response-filename').textContent = ''; _updateAddedCount(); $('chatbot-step2').style.display = 'block'; prepBtn.innerHTML = '✓ Post ready — see steps below'; } // ── Provider toggle ──────────────────────────────────────────────────────── const _ollamaEnabled = document.body.dataset.ollamaEnabled === 'true'; // Hosted deploy can't bulk-geocode server-side; the browser does it (see geocodeClientSide). const _clientGeocode = document.body.dataset.clientGeocode === 'true'; let activeProvider = _ollamaEnabled ? 'ollama' : 'anthropic'; function setProvider(p) { activeProvider = p; $('btn-anthropic').classList.toggle('active', p === 'anthropic'); if (_ollamaEnabled) $('btn-ollama').classList.toggle('active', p === 'ollama'); $('btn-chatbot').classList.toggle('active', p === 'chatbot'); $('models-anthropic').style.display = p === 'anthropic' ? 'block' : 'none'; if (_ollamaEnabled) $('models-ollama').style.display = p === 'ollama' ? 'block' : 'none'; $('models-chatbot').style.display = p === 'chatbot' ? 'block' : 'none'; const isChatbot = p === 'chatbot'; if (_urlMode) { _syncUrlModeButtons(/instagram\.com/.test(urlInput.value.trim())); } else { $('run-btn').style.display = isChatbot ? 'none' : 'block'; $('chatbot-prepare-btn').style.display = isChatbot ? 'block' : 'none'; if (isChatbot) $('chatbot-prepare-btn').disabled = runBtn.disabled; } } $('btn-anthropic').addEventListener('click', () => setProvider('anthropic')); if (_ollamaEnabled) $('btn-ollama').addEventListener('click', () => setProvider('ollama')); $('btn-chatbot').addEventListener('click', () => setProvider('chatbot')); // ── Chatbot handoff ──────────────────────────────────────────────────────── let _chatbotJobId = null; let _chatbotPromptText = ''; let _chatbotChunkCount = 1; let _chatbotResponses = []; // accumulated pasted replies (multi-chunk mode) function _updateAddedCount() { const el = $('chatbot-added-count'); if (_chatbotChunkCount <= 1) { el.textContent = ''; return; } el.textContent = `${_chatbotResponses.length} of ${_chatbotChunkCount} added`; } $('chatbot-prepare-btn').addEventListener('click', async () => { if (_urlMode) { await _urlChatbotPrepare(); return; } const prepBtn = $('chatbot-prepare-btn'); if (!fileInput.files[0] && !_pendingJsonBlob) return; prepBtn.innerHTML = 'Preparing…'; prepBtn.disabled = true; const uploadFile = _pendingJsonBlob ? new File([_pendingJsonBlob], 'saved_posts.json', { type: 'application/json' }) : fileInput.files[0]; const fd = new FormData(); fd.append('file', uploadFile); let res; try { res = await fetch('/chatbot-prepare', { method: 'POST', body: fd }); if (!res.ok) throw new Error(await res.text()); } catch (err) { prepBtn.innerHTML = 'Error: ' + err.message; prepBtn.disabled = false; return; } const data = await res.json(); _chatbotJobId = data.job_id; _chatbotPromptText = data.prompt; _chatbotChunkCount = data.chunk_count || 1; _chatbotResponses = []; // Split the export into chunk files and render a download link for each const posts = data.export_posts; const size = data.chunk_size || posts.length || 1; const dl = $('chatbot-downloads'); dl.innerHTML = ''; for (let c = 0; c < _chatbotChunkCount; c++) { const slice = posts.slice(c * size, (c + 1) * size); const blob = new Blob([JSON.stringify(slice, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.className = 'btn btn-outline'; a.style.textDecoration = 'none'; a.download = _chatbotChunkCount > 1 ? `instagram_places_${c + 1}of${_chatbotChunkCount}.json` : 'instagram_places.json'; a.href = URL.createObjectURL(blob); a.textContent = _chatbotChunkCount > 1 ? `⬇ File ${c + 1} of ${_chatbotChunkCount}` : '⬇ Download posts JSON'; dl.appendChild(a); } const multi = _chatbotChunkCount > 1; $('chatbot-chunk-note').style.display = multi ? 'block' : 'none'; $('chatbot-add-response-btn').style.display = multi ? 'inline-block' : 'none'; if (multi) $('chatbot-chunk-count').textContent = _chatbotChunkCount; $('chatbot-process-btn').disabled = true; $('chatbot-response-area').value = ''; $('chatbot-response-file').value = ''; $('chatbot-response-filename').textContent = ''; _updateAddedCount(); $('chatbot-step2').style.display = 'block'; prepBtn.innerHTML = '✓ File ready — see steps below'; }); $('chatbot-copy-prompt-btn').addEventListener('click', () => { navigator.clipboard.writeText(_chatbotPromptText).then(() => { const btn = $('chatbot-copy-prompt-btn'); btn.textContent = '✓ Copied!'; setTimeout(() => { btn.textContent = '📋 Copy prompt'; }, 2000); }); }); // Single-chunk: enable Process as soon as there's text. Multi-chunk: Process is // gated on accumulated responses, so the textarea only feeds "Add response". $('chatbot-response-area').addEventListener('input', () => { if (_chatbotChunkCount <= 1) { $('chatbot-process-btn').disabled = !$('chatbot-response-area').value.trim(); } }); $('chatbot-add-response-btn').addEventListener('click', () => { const area = $('chatbot-response-area'); const txt = area.value.trim(); if (!txt) return; _chatbotResponses.push(txt); area.value = ''; _updateAddedCount(); $('chatbot-process-btn').disabled = _chatbotResponses.length === 0; }); // Upload the chatbot reply as a file (when it's too long to copy-paste). $('chatbot-response-file').addEventListener('change', async (e) => { const files = Array.from(e.target.files || []); let added = 0; const names = []; for (const f of files) { try { const txt = (await f.text()).trim(); if (txt) { _chatbotResponses.push(txt); added++; names.push(f.name); } } catch { /* unreadable file — skip */ } } e.target.value = ''; // allow re-selecting the same file if (!added) { $('chatbot-response-filename').textContent = '⚠ that file was empty'; return; } $('chatbot-response-filename').textContent = '✓ ' + names.join(', '); if (_chatbotChunkCount > 1) { _updateAddedCount(); } else { $('chatbot-added-count').textContent = `${_chatbotResponses.length} response file${_chatbotResponses.length === 1 ? '' : 's'} added`; } $('chatbot-process-btn').disabled = _chatbotResponses.length === 0 && !$('chatbot-response-area').value.trim(); }); $('chatbot-process-btn').addEventListener('click', async () => { const processBtn = $('chatbot-process-btn'); // Include anything still sitting in the textarea (single-chunk, or a forgotten "Add") const pending = $('chatbot-response-area').value.trim(); const responses = _chatbotResponses.slice(); if (pending) responses.push(pending); if (responses.length === 0 || !_chatbotJobId) return; processBtn.innerHTML = 'Processing…'; processBtn.disabled = true; let res; try { res = await fetch('/chatbot-process/' + _chatbotJobId, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ responses }), }); if (!res.ok) throw new Error(await res.text()); } catch (err) { processBtn.innerHTML = 'Error: ' + err.message; processBtn.disabled = false; return; } currentJobId = _chatbotJobId; $('extract-step-label').textContent = 'Geocode & export (chatbot results)'; $('upload-card').style.display = 'none'; $('model-card').style.display = 'none'; $('run-btn-wrap').style.display = 'none'; $('progress-section').style.display = 'block'; listenProgress(_chatbotJobId); }); // ── Model picker ─────────────────────────────────────────────────────────── document.querySelectorAll('#model-grid .model-option').forEach(opt => { opt.addEventListener('click', () => { document.querySelectorAll('#model-grid .model-option').forEach(o => o.classList.remove('selected')); opt.classList.add('selected'); opt.querySelector('input').checked = true; }); }); document.querySelectorAll('#model-grid-ollama .model-option').forEach(opt => { opt.addEventListener('click', () => { document.querySelectorAll('#model-grid-ollama .model-option').forEach(o => o.classList.remove('selected')); opt.classList.add('selected'); opt.querySelector('input').checked = true; }); }); function selectedModel() { return document.querySelector('input[name=model]:checked')?.value || document.body.dataset.defaultModel; } function selectedOllamaModel() { return document.querySelector('input[name=ollama_model]:checked')?.value || document.body.dataset.defaultOllamaModel; } // ── Upload & start pipeline ──────────────────────────────────────────────── runBtn.addEventListener('click', async () => { if (_urlMode) { await _submitUrl(); return; } if (!fileInput.files[0] && !_pendingJsonBlob) return; runBtn.innerHTML = 'Starting…'; runBtn.disabled = true; const uploadFile = _pendingJsonBlob ? new File([_pendingJsonBlob], 'saved_posts.json', { type: 'application/json' }) : fileInput.files[0]; const fd = new FormData(); fd.append('file', uploadFile); fd.append('provider', activeProvider); fd.append('model', selectedModel()); fd.append('ollama_model', selectedOllamaModel()); fd.append('api_key', ($('anthropic-api-key').value || '').trim()); // Living library: send the cached library so the pipeline warm-starts and // skips the LLM on posts already extracted (saves cost on a re-export). const _lib = _readSavedLibrary(); if (_lib && _lib.csv) fd.append('merge_into', _lib.csv); let res; try { res = await fetch('/upload', { method: 'POST', body: fd }); if (!res.ok) throw new Error(await res.text()); } catch (err) { runBtn.innerHTML = 'Error: ' + err.message; runBtn.disabled = false; return; } const { job_id } = await res.json(); currentJobId = job_id; const modelLabel = activeProvider === 'ollama' ? `Ollama / ${selectedOllamaModel()}` : `Claude / ${selectedModel()}`; $('extract-step-label').textContent = `Extract places with ${modelLabel}`; $('upload-card').style.display = 'none'; $('model-card').style.display = 'none'; $('run-btn-wrap').style.display = 'none'; $('progress-section').style.display = 'block'; listenProgress(job_id); }); // ── SSE progress ─────────────────────────────────────────────────────────── function setStep(id, state) { const el = $(id); el.classList.remove('active', 'done'); if (state === 'active') el.classList.add('active'); if (state === 'done') el.classList.add('done'); } function listenProgress(jobId) { const es = new EventSource('/progress/' + jobId); es.onmessage = e => { const d = JSON.parse(e.data); // Update progress bar $('progress-fill').style.width = (d.progress || 0) + '%'; $('status-msg').textContent = d.message || ''; $('status-msg').classList.toggle('error', d.step === 'error'); // Animate steps const stepMap = { extract: 's-extract', geocode: 's-geocode', export: 's-export' }; const steps = ['extract', 'geocode', 'export']; const idx = steps.indexOf(d.step); steps.forEach((s, i) => { if (i < idx) setStep(stepMap[s], 'done'); else if (i === idx) setStep(stepMap[s], 'active'); else setStep(stepMap[s], ''); }); if (d.step === 'done') { steps.forEach(s => setStep(stepMap[s], 'done')); es.close(); showDownload(d); } if (d.step === 'error') { es.close(); } }; es.onerror = () => { $('status-msg').textContent = 'Connection lost. Try refreshing.'; $('status-msg').classList.add('error'); es.close(); }; } // ── Download / done ──────────────────────────────────────────────────────── function showDownload(d) { $('progress-section').style.display = 'none'; $('download-btn').onclick = () => { window.location = '/download/' + currentJobId; }; $('download-geojson-btn').onclick = () => { window.location = '/download/' + currentJobId + '/geojson'; }; $('download-csv-btn').onclick = () => { window.location = '/download/' + currentJobId + '/csv'; }; // Jump to Explore, reveal sub-tab bar, default to Roulette (auto-spins) showTopTab('explore'); showSubTabBar(currentJobId); showSubTab('roulette'); showNudge(); lastJobCost = d.cost || 0; loadResults(currentJobId); } // ── Roulette ─────────────────────────────────────────────────────────────── let rouletteAllStatuses = false; function _setRouletteAllStatuses(val) { rouletteAllStatuses = val; const btn = $('status-toggle'); btn.classList.toggle('active', !val); btn.textContent = val ? 'All places' : 'Unvisited only'; } $('status-toggle').addEventListener('click', () => { _setRouletteAllStatuses(!rouletteAllStatuses); }); $('radius-slider').addEventListener('input', () => { $('radius-val').textContent = $('radius-slider').value + ' km'; }); $('city-input').addEventListener('input', () => { const hasCity = $('city-input').value.trim().length > 0; $('city-clear').style.display = hasCity ? 'inline' : 'none'; $('radius-row').classList.toggle('dimmed', hasCity); }); $('city-clear').addEventListener('click', () => { $('city-input').value = ''; $('city-clear').style.display = 'none'; $('radius-row').classList.remove('dimmed'); $('city-input').focus(); }); $('spin-btn').addEventListener('click', async () => { $('spin-btn').innerHTML = 'Finding a spot…'; $('spin-btn').disabled = true; const params = new URLSearchParams({ job_id: currentJobId || '' }); const cityVal = ($('city-input').value || '').trim(); if (cityVal) { params.set('city', cityVal); } else if (userLat !== null) { params.set('lat', userLat); params.set('lng', userLng); params.set('radius_km', $('radius-slider').value); } if (rouletteAllStatuses) params.set('all_statuses', '1'); if (filterState.foodOnly) params.set('food_only', '1'); const rouletteCat = $('roulette-category').value; if (rouletteCat) params.set('category', rouletteCat); let r; try { const res = await fetch('/roulette?' + params); r = await res.json(); } catch(e) { $('spin-btn').innerHTML = '🎲Spin the wheel'; $('spin-btn').disabled = false; return; } $('spin-btn').innerHTML = '🎲Spin again'; $('spin-btn').disabled = false; // Auto-flip to "All places" when all unvisited spots are exhausted if (r.error && r.error.includes('No unvisited places')) { _setRouletteAllStatuses(true); $('spin-btn').click(); return; } const card = $('pick-card'); card.style.display = 'none'; // reset for re-animation void card.offsetWidth; // reflow card.style.display = 'block'; if (r.error) { $('pick-name').textContent = r.error; $('pick-loc').textContent = ''; $('pick-link').style.display = 'none'; $('pick-no-loc').style.display = 'none'; return; } const _k = v => v && v.toUpperCase() !== 'UNKNOWN'; $('pick-name').textContent = r.name; $('pick-loc').textContent = [r.city, r.country].filter(_k).join(', '); const metaParts = [r.category, r.cuisine, r.price_range, r.occasion].filter(v => v && v !== 'UNKNOWN'); $('pick-meta').textContent = metaParts.join(' · '); const hl = r.highlight && r.highlight !== 'UNKNOWN' ? r.highlight : ''; $('pick-highlight').textContent = hl ? `Must-order: ${hl}` : ''; $('pick-highlight').style.display = hl ? 'block' : 'none'; $('pick-link').href = r.url || '#'; $('pick-link').style.display = r.url ? 'inline-flex' : 'none'; const bylineParts = []; if (r.creator) bylineParts.push(`via @${r.creator}`); if (r.saved_at) bylineParts.push(`saved ${r.saved_at}`); $('pick-byline').textContent = bylineParts.join(' · '); $('pick-no-loc').style.display = (!r.lat && userLat !== null) ? 'block' : 'none'; }); // ── Results browser ──────────────────────────────────────────────────────── let allRows = []; let lastJobCost = 0; let hasEdits = false; const _storedFoodOnly = localStorage.getItem('foodOnly'); let filterState = { search: '', country: '', category: '', cuisine: '', status: '', prices: new Set(), missingOnly: false, foodOnly: _storedFoodOnly === null ? true : _storedFoodOnly === 'true', }; async function loadResults(jobId) { try { const res = await fetch('/results/' + jobId); if (!res.ok) return; const data = await res.json(); allRows = data.rows; renderStats(); populateFilterDropdowns(); $('stats-bar').style.display = 'block'; $('results-section').style.display = 'block'; applyFilters(); updateTabBadges(); if (_map) setTimeout(() => _map.invalidateSize(), 60); // "New since last visit" banner const lsKey = 'lastViewed_' + jobId; const lastViewed = localStorage.getItem(lsKey); if (lastViewed) { const since = new Date(lastViewed); const newCount = allRows.filter(r => { if (!r.saved_at) return false; try { return new Date(r.saved_at) > since; } catch { return false; } }).length; if (newCount > 0) { $('new-banner-text').textContent = `${newCount} new place${newCount === 1 ? '' : 's'} since your last visit`; $('new-banner').style.display = 'block'; let _autoDismiss = setTimeout(() => { $('new-banner').style.display = 'none'; }, 8000); $('new-banner-dismiss').onclick = () => { clearTimeout(_autoDismiss); $('new-banner').style.display = 'none'; }; } } localStorage.setItem(lsKey, new Date().toISOString()); // Cache the library on this device so a later visit can restore it. cacheLibrary(jobId); // Hosted mode: the server didn't geocode (public-OSM bulk use is blocked), // so the browser pins each place from the visitor's own IP. if (_clientGeocode) geocodeClientSide(); } catch (e) { console.error('loadResults:', e); } } function renderStats() { const countries = new Set(allRows.map(r => r.country).filter(v => v && v !== 'UNKNOWN')); const pinned = allRows.filter(r => r.geocoded).length; const unvisited = allRows.filter(r => !r.status || r.status === 'unvisited').length; $('stat-total').textContent = `${allRows.length} (${unvisited} unvisited)`; $('stat-countries').textContent = countries.size; $('stat-pinned').textContent = `${pinned} / ${allRows.length}`; $('stat-cost').textContent = lastJobCost ? `$${lastJobCost}` : '$0'; } function populateFilterDropdowns() { const countries = [...new Set(allRows.map(r => r.country).filter(v => v && v !== 'UNKNOWN'))].sort(); const cuisines = [...new Set(allRows.map(r => r.cuisine).filter(v => v && v !== 'UNKNOWN'))].sort(); const cEl = $('filter-country'); cEl.innerHTML = ''; countries.forEach(c => { const o = document.createElement('option'); o.value = o.textContent = c; cEl.appendChild(o); }); const qEl = $('filter-cuisine'); qEl.innerHTML = ''; cuisines.forEach(c => { const o = document.createElement('option'); o.value = o.textContent = c; qEl.appendChild(o); }); // Populate roulette location datalist (cities + countries) const _knownVal = v => v && v.toUpperCase() !== 'UNKNOWN'; const locations = [...new Set([ ...allRows.map(r => r.city).filter(_knownVal), ...allRows.map(r => r.country).filter(_knownVal), ])].sort(); const dl = $('city-list'); dl.innerHTML = ''; locations.forEach(c => { const o = document.createElement('option'); o.value = c; dl.appendChild(o); }); } function applyFilters() { let rows = allRows; const q = filterState.search.toLowerCase(); if (filterState.foodOnly) rows = rows.filter(r => FOOD_CATEGORIES.has(r.category)); if (q) rows = rows.filter(r => (r.name || '').toLowerCase().includes(q) || (r.city || '').toLowerCase().includes(q)); if (filterState.country) rows = rows.filter(r => r.country === filterState.country); if (filterState.category) rows = rows.filter(r => r.category === filterState.category); if (filterState.cuisine) rows = rows.filter(r => r.cuisine === filterState.cuisine); if (filterState.status) rows = rows.filter(r => (r.status || 'unvisited') === filterState.status); if (filterState.prices.size > 0) rows = rows.filter(r => filterState.prices.has(r.price_range)); if (filterState.missingOnly) rows = rows.filter(r => !r.geocoded); // Keep food toggle chip in sync const ft = $('food-toggle'); if (ft) ft.classList.toggle('active', filterState.foodOnly); // Float just-merged "new" places to the top (stable; only after a merge). if (allRows.some(r => r._isNew)) { rows = [...rows].sort((a, b) => (b._isNew ? 1 : 0) - (a._isNew ? 1 : 0)); } renderGrid(rows); renderMap(rows); $('result-count').textContent = rows.length === allRows.length ? `${allRows.length} places` : `Showing ${rows.length} of ${allRows.length}`; } // ── QC map (Leaflet) ───────────────────────────────────────────────────────── let _map = null, _markerLayer = null; function renderMap(rows) { if (typeof L === 'undefined') return; // Leaflet failed to load — skip silently const geo = rows.filter(r => r.geocoded); if (!_map) { _map = L.map('results-map', { scrollWheelZoom: false }); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors', }).addTo(_map); _markerLayer = L.layerGroup().addTo(_map); } _markerLayer.clearLayers(); const esc = s => (s == null ? '' : String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))); const bounds = []; geo.forEach(r => { const lat = parseFloat(r.lat), lng = parseFloat(r.lng); if (isNaN(lat) || isNaN(lng)) return; const meta = [val(r.category), val(r.cuisine)].filter(Boolean).join(' · '); const loc = [val(r.city), val(r.country)].filter(Boolean).join(', '); const hl = val(r.highlight); const link = r.instagram_url ? `
View reel ↗
` : ''; const html = `
${esc(val(r.name) || 'Unknown place')}
` + (meta ? `
${esc(meta)}
` : '') + (loc ? `
${esc(loc)}
` : '') + (hl ? `
⭐ ${esc(hl)}
` : '') + link; L.marker([lat, lng]).bindPopup(html).addTo(_markerLayer); bounds.push([lat, lng]); }); if (bounds.length) _map.fitBounds(bounds, { padding: [30, 30], maxZoom: 13 }); else _map.setView([20, 0], 2); const missing = rows.length - geo.length; $('map-note').innerHTML = `📍 ${geo.length} of ${rows.length} mapped` + (missing > 0 ? ` · ${missing} missing coordinates (use “📍 Missing pins” to fix). Spot a pin in the wrong place? Edit its card below.` : ` · spot-check for any pin in the wrong place before exporting.`); } function renderGrid(rows) { const grid = $('results-grid'); grid.innerHTML = ''; rows.forEach((row, i) => { const card = makeCard(row); card.style.animationDelay = Math.min(i * 25, 300) + 'ms'; grid.appendChild(card); }); } function val(v) { return (!v || v === 'UNKNOWN') ? '' : v; } function makeCard(row) { const card = document.createElement('div'); card.className = 'rest-card' + (row.geocoded ? '' : ' missing-pin') + (row._isNew ? ' is-new' : ''); card.dataset.url = row.instagram_url; // "New" badge — set on rows just added by a merge (transient, resets on reload) if (row._isNew) { const nb = document.createElement('div'); nb.className = 'new-badge'; nb.textContent = '✨ New'; card.appendChild(nb); } // Pin badge const badge = document.createElement('div'); badge.className = 'pin-badge ' + (row.geocoded ? 'pin-ok' : 'pin-missing'); badge.title = row.geocoded ? 'Pinned on map' : 'No coordinates yet'; card.appendChild(badge); // Status badge const STATUS_CYCLE = ['unvisited', 'want_to_go', 'visited', 'closed']; const STATUS_LABEL = { unvisited: '🔖 Unvisited', visited: '✅ Visited', want_to_go: '❤️ Want to go', closed: '🚫 Closed' }; const sbadge = document.createElement('div'); const initStatus = row.status || 'unvisited'; sbadge.className = 'status-badge s-' + initStatus; sbadge.textContent = STATUS_LABEL[initStatus] || '🔖 Unvisited'; if (initStatus === 'closed') card.classList.add('status-closed'); let sbLocked = false; sbadge.addEventListener('click', async () => { if (sbLocked) return; sbLocked = true; const cur = row.status || 'unvisited'; const next = STATUS_CYCLE[(STATUS_CYCLE.indexOf(cur) + 1) % STATUS_CYCLE.length]; sbadge.className = 'status-badge s-' + next; sbadge.textContent = STATUS_LABEL[next]; card.classList.toggle('status-closed', next === 'closed'); try { await fetch(`/results/${currentJobId}`, { method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({url: row.instagram_url, updates: {status: next}}), }); row.status = next; renderStats(); updateTabBadges(); cacheLibrarySoon(); if (filterState.status && filterState.status !== next) applyFilters(); } catch { sbadge.className = 'status-badge s-' + cur; sbadge.textContent = STATUS_LABEL[cur]; card.classList.toggle('status-closed', cur === 'closed'); } setTimeout(() => { sbLocked = false; }, 300); }); card.appendChild(sbadge); // Name const nameDiv = document.createElement('div'); nameDiv.className = 'card-name'; const nameSpan = makeEditable(row, 'name'); if (!val(row.name)) nameSpan.classList.add('is-placeholder'); nameDiv.appendChild(nameSpan); card.appendChild(nameDiv); // Location const locDiv = document.createElement('div'); locDiv.className = 'card-loc'; const citySpan = makeEditable(row, 'city'); const stateSpan = makeEditable(row, 'state'); locDiv.appendChild(citySpan); if (val(row.city) && val(row.state)) { locDiv.appendChild(document.createTextNode(' · ')); locDiv.appendChild(stateSpan); } else if (val(row.state)) { locDiv.appendChild(stateSpan); } card.appendChild(locDiv); // Category + cuisine + price tags if (val(row.category) || val(row.cuisine) || val(row.price_range)) { const tags = document.createElement('div'); tags.className = 'card-tags'; if (val(row.category)) { const ch = document.createElement('span'); ch.className = 'cuisine-chip'; ch.style.background = '#e8f0fe'; ch.style.color = '#1a56db'; ch.textContent = row.category; tags.appendChild(ch); } if (val(row.cuisine)) { const ch = document.createElement('span'); ch.className = 'cuisine-chip'; ch.appendChild(makeEditable(row, 'cuisine')); tags.appendChild(ch); } if (val(row.price_range)) { const ch = document.createElement('span'); ch.className = 'price-tag'; ch.textContent = row.price_range; tags.appendChild(ch); } card.appendChild(tags); } // Highlight if (val(row.highlight)) { const hl = document.createElement('div'); hl.className = 'card-highlight'; hl.textContent = `Must-order: ${row.highlight}`; card.appendChild(hl); } // Occasion if (val(row.occasion)) { const occ = document.createElement('div'); occ.className = 'card-occasion'; occ.appendChild(document.createTextNode('Occasion: ')); occ.appendChild(makeEditable(row, 'occasion')); card.appendChild(occ); } // Byline const byline = document.createElement('div'); byline.className = 'card-byline'; const bp = []; if (row.creator) bp.push(`via @${row.creator}`); if (row.saved_at) bp.push(row.saved_at); byline.textContent = bp.join(' · '); card.appendChild(byline); // Instagram link if (row.instagram_url) { const a = document.createElement('a'); a.className = 'card-link'; a.href = row.instagram_url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'View on Instagram ↗'; card.appendChild(a); } // Re-geocode button (unpinned only) if (!row.geocoded) { const btn = document.createElement('button'); btn.className = 'regeocode-btn'; btn.textContent = '📍 Try to geocode'; btn.addEventListener('click', () => regeocodeRow(row, card, btn, badge)); card.appendChild(btn); } return card; } // ── Editable spans ───────────────────────────────────────────────────────── const EDITABLE_FIELDS = new Set(['name','city','state','category','cuisine','occasion']); const PLACEHOLDERS = { name: 'Unknown place', city: '', state: '', category: '', cuisine: '', occasion: '' }; function makeEditable(row, field) { const span = document.createElement('span'); if (EDITABLE_FIELDS.has(field)) span.className = 'editable'; const v = val(row[field]); span.textContent = v || PLACEHOLDERS[field] || ''; if (!v && PLACEHOLDERS[field]) span.classList.add('is-placeholder'); if (EDITABLE_FIELDS.has(field)) { span.addEventListener('click', e => { e.stopPropagation(); startEdit(span, row, field); }); } return span; } function startEdit(span, row, field) { if (span.dataset.editing) return; span.dataset.editing = '1'; const oldVal = val(row[field]); const input = document.createElement('input'); input.type = 'text'; input.className = 'edit-input'; input.value = oldVal; span.replaceWith(input); input.focus(); input.select(); const commit = async () => { const newVal = input.value.trim(); if (newVal === oldVal) { input.replaceWith(span); delete span.dataset.editing; return; } input.disabled = true; const ok = await patchField(row.instagram_url, field, newVal); if (ok) { row[field] = newVal || 'UNKNOWN'; span.textContent = newVal || PLACEHOLDERS[field] || ''; span.classList.toggle('is-placeholder', !newVal && !!PLACEHOLDERS[field]); span.classList.remove('editable'); void span.offsetWidth; span.classList.add('editable'); flashEl(span, 'flash-ok'); markEdited(); } else { flashEl(input, 'flash-err'); input.disabled = false; input.focus(); return; } input.replaceWith(span); delete span.dataset.editing; }; input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); commit(); } if (e.key === 'Escape') { input.replaceWith(span); delete span.dataset.editing; } }); input.addEventListener('blur', commit); } async function patchField(url, field, value) { try { const res = await fetch(`/results/${currentJobId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, updates: { [field]: value } }), }); if (res.ok) cacheLibrarySoon(); return res.ok; } catch { return false; } } // ── Re-geocode ───────────────────────────────────────────────────────────── async function regeocodeRow(row, card, btn, badge) { btn.disabled = true; btn.textContent = '📍 Geocoding…'; try { const res = await fetch(`/regeocode/${currentJobId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: row.instagram_url }), }); const data = await res.json(); if (data.geocoded) { row.lat = data.lat; row.lng = data.lng; row.geocoded = true; card.classList.remove('missing-pin'); badge.className = 'pin-badge pin-ok'; badge.title = 'Pinned on map'; btn.remove(); markEdited(); renderStats(); cacheLibrarySoon(); } else { btn.textContent = '📍 Try to geocode'; btn.title = 'Geocoding failed — try editing the city name first'; btn.disabled = false; } } catch { btn.textContent = '📍 Try to geocode'; btn.disabled = false; } } // ── Client-side geocoder (hosted mode) ─────────────────────────────────────── // Port of pipeline/geocode.py: the browser geocodes each row against public OSM // from the visitor's own IP (HTTPS→HTTPS, works in every browser). No server // key, no shared quota. Throttled to ~1 req/s per OSM policy. const GEO_COUNTRY_CODES = { afghanistan:'af',albania:'al',argentina:'ar',australia:'au',austria:'at',bangladesh:'bd', belgium:'be',brazil:'br',cambodia:'kh',canada:'ca',china:'cn',croatia:'hr',czechia:'cz', 'czech republic':'cz',denmark:'dk',egypt:'eg',ethiopia:'et',finland:'fi',france:'fr', germany:'de',ghana:'gh',greece:'gr','hong kong':'hk',hungary:'hu',india:'in',indonesia:'id', ireland:'ie',israel:'il',italy:'it',japan:'jp',kenya:'ke','south korea':'kr',korea:'kr', malaysia:'my',mexico:'mx',morocco:'ma',myanmar:'mm',nepal:'np',netherlands:'nl', 'new zealand':'nz',nigeria:'ng',norway:'no',pakistan:'pk',peru:'pe',philippines:'ph', poland:'pl',portugal:'pt',romania:'ro',russia:'ru','saudi arabia':'sa',singapore:'sg', 'south africa':'za',spain:'es','sri lanka':'lk',sweden:'se',switzerland:'ch',taiwan:'tw', thailand:'th',turkey:'tr',uae:'ae','united arab emirates':'ae',uk:'gb','united kingdom':'gb', usa:'us','united states':'us','united states of america':'us',vietnam:'vn', }; const GEO_CITY_ALIASES = { bengaluru:'bangalore',bengalore:'bangalore',bombay:'mumbai',madras:'chennai',calcutta:'kolkata', 'new york city':'new york',nyc:'new york',sf:'san francisco',la:'los angeles', dc:'washington','washington dc':'washington','washington d.c.':'washington', }; const GEO_CITY_FIELDS = ['city','town','village','municipality','city_district','district','suburb']; const GEO_MAX_ADDRESS_KM = 50; const GEO_MIN_GAP_MS = 1100; let _geoLastCall = 0; let _geoRunning = false; function geoKnown(v) { return v && String(v).toUpperCase() !== 'UNKNOWN' && String(v).trim() !== ''; } function geoNormCity(name) { const n = String(name || '').toLowerCase().trim().replace(/\.+$/, ''); return GEO_CITY_ALIASES[n] || n; } function geoCountryCode(country) { return GEO_COUNTRY_CODES[String(country || '').toLowerCase().trim()] || null; } function geoHaversineKm(lat1, lon1, lat2, lon2) { const R = 6371, rad = d => d * Math.PI / 180; const dlat = rad(lat2 - lat1), dlon = rad(lon2 - lon1); const a = Math.sin(dlat / 2) ** 2 + Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dlon / 2) ** 2; return R * 2 * Math.asin(Math.sqrt(a)); } function geoResultCities(addr) { const out = new Set(); for (const f of GEO_CITY_FIELDS) { if (addr[f]) out.add(geoNormCity(addr[f])); } return out; } function geoCityMatches(expected, addr) { const exp = geoNormCity(expected); if (!exp) return true; for (const rc of geoResultCities(addr)) { if (exp === rc) return true; if (exp.length >= 4 && rc.includes(exp)) return true; if (rc.length >= 4 && exp.includes(rc)) return true; } return false; } async function _geoThrottle() { const wait = GEO_MIN_GAP_MS - (Date.now() - _geoLastCall); if (wait > 0) await new Promise(r => setTimeout(r, wait)); _geoLastCall = Date.now(); } async function geoSearch(query, limit) { await _geoThrottle(); const qs = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: String(limit) }); const res = await fetch('https://nominatim.openstreetmap.org/search?' + qs, { headers: { Accept: 'application/json' } }); if (!res.ok) return []; return await res.json(); } async function geoNominatim(query, country, city) { const expCode = geoKnown(country) ? geoCountryCode(country) : null; const validateCity = geoKnown(city), validateCountry = expCode !== null; const limit = (validateCountry || validateCity) ? 5 : 1; let results; try { results = await geoSearch(query, limit); } catch { return null; } for (const r of results) { const addr = r.address || {}; if (validateCountry && (addr.country_code || '').toLowerCase() !== expCode) continue; if (validateCity && !geoCityMatches(city, addr)) continue; return [parseFloat(r.lat), parseFloat(r.lon)]; } return null; } async function geoCityCentre(city, code) { try { const results = await geoSearch(code ? `${city}, ${code}` : city, 1); if (results.length) return [parseFloat(results[0].lat), parseFloat(results[0].lon)]; } catch {} return null; } async function geoGeocodeRow(row) { const { name, city, state, country, address } = row; const expCode = geoKnown(country) ? geoCountryCode(country) : null; // 1. Full address → country + city + proximity gate if (geoKnown(address)) { let hit = await geoNominatim(String(address).slice(0, 200), country, city); if (hit && geoKnown(city)) { const centre = await geoCityCentre(city, expCode); if (centre && geoHaversineKm(hit[0], hit[1], centre[0], centre[1]) > GEO_MAX_ADDRESS_KM) hit = null; } if (hit) return hit; } // 2. Name + city + country if (geoKnown(name) && geoKnown(city)) { const parts = [name, city, state, country].filter(geoKnown); const hit = await geoNominatim(parts.join(', '), country, city); if (hit) return hit; } // 3. Name + country (city unknown) if (geoKnown(name) && !geoKnown(city) && geoKnown(country)) { const hit = await geoNominatim(`${name}, ${country}`, country, ''); if (hit) return hit; } return null; } function geoCacheKey(r) { return [r.name, r.city, r.state, r.country, r.address].map(s => String(s || '').trim().toLowerCase()).join('|'); } function geoLoadCache() { try { return JSON.parse(localStorage.getItem('geocodeCache') || '{}'); } catch { return {}; } } function geoSaveCache(c) { try { localStorage.setItem('geocodeCache', JSON.stringify(c)); } catch {} } async function geocodeClientSide() { if (_geoRunning) return; const todo = allRows.filter(r => !r.geocoded && (geoKnown(r.name) || geoKnown(r.address))); if (!todo.length) return; _geoRunning = true; const banner = $('geocode-banner'), txt = $('geocode-banner-text'); if (banner) banner.style.display = 'block'; const cache = geoLoadCache(); let done = 0, found = 0; for (const row of todo) { const key = geoCacheKey(row); let coords = key in cache ? cache[key] : undefined; if (coords === undefined) { coords = await geoGeocodeRow(row); // null if no confident match cache[key] = coords; geoSaveCache(cache); } done++; if (coords) { row.lat = coords[0].toFixed(7); row.lng = coords[1].toFixed(7); row.geocoded = true; found++; try { await fetch(`/set-coords/${currentJobId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: row.instagram_url, lat: row.lat, lng: row.lng }), }); } catch {} } if (txt) txt.textContent = `Pinning places in your browser… ${done}/${todo.length} (${found} found). Keep this tab open.`; if (done % 5 === 0) { renderStats(); renderMap(allRows); } } renderStats(); applyFilters(); if (txt) txt.textContent = `Done — pinned ${found} of ${todo.length}. Regenerate the KML below to include the new pins.`; if (found) { markEdited(); cacheLibrarySoon(); } setTimeout(() => { if (banner) banner.style.display = 'none'; }, 8000); _geoRunning = false; } // ── Action bar ───────────────────────────────────────────────────────────── function markEdited() { hasEdits = true; $('action-bar').style.display = 'flex'; } $('regen-btn').addEventListener('click', async () => { const btn = $('regen-btn'); btn.innerHTML = 'Regenerating…'; btn.disabled = true; try { const res = await fetch(`/export/${currentJobId}`, { method: 'POST' }); if (!res.ok) throw new Error(); window.location = `/download/${currentJobId}`; $('action-bar').style.display = 'none'; hasEdits = false; } catch { btn.textContent = '⚡ Regenerate & Download KML'; btn.disabled = false; } }); // ── Utility ──────────────────────────────────────────────────────────────── function flashEl(el, cls) { el.classList.add(cls); setTimeout(() => el.classList.remove(cls), 600); } // ── Filter events ────────────────────────────────────────────────────────── let _searchTimer; $('filter-search').addEventListener('input', () => { clearTimeout(_searchTimer); _searchTimer = setTimeout(() => { filterState.search = $('filter-search').value.trim(); applyFilters(); }, 150); }); $('filter-country').addEventListener('change', () => { filterState.country = $('filter-country').value; applyFilters(); }); $('filter-category').addEventListener('change', () => { filterState.category = $('filter-category').value; applyFilters(); }); $('filter-cuisine').addEventListener('change', () => { filterState.cuisine = $('filter-cuisine').value; applyFilters(); }); $('filter-status').addEventListener('change', () => { filterState.status = $('filter-status').value; applyFilters(); }); document.querySelectorAll('.price-chip').forEach(btn => { btn.addEventListener('click', () => { btn.classList.toggle('active'); const p = btn.dataset.price; if (filterState.prices.has(p)) filterState.prices.delete(p); else filterState.prices.add(p); applyFilters(); }); }); $('missing-toggle').addEventListener('click', () => { filterState.missingOnly = !filterState.missingOnly; $('missing-toggle').classList.toggle('active', filterState.missingOnly); applyFilters(); }); const _foodToggleEl = $('food-toggle'); if (_foodToggleEl) _foodToggleEl.classList.toggle('active', filterState.foodOnly); _foodToggleEl.addEventListener('click', () => { filterState.foodOnly = !filterState.foodOnly; localStorage.setItem('foodOnly', filterState.foodOnly); applyFilters(); }); // ── Feature-request feedback modal ──────────────────────────────────────── const FEEDBACK_REPO = 'https://github.com/harshithbelagur/instagram-restaurant-mapper'; $('feedback-btn').addEventListener('click', () => { $('feedback-overlay').classList.add('open'); setTimeout(() => $('feedback-title-input').focus(), 60); }); function _closeFeedback() { $('feedback-overlay').classList.remove('open'); } $('feedback-close').addEventListener('click', _closeFeedback); $('feedback-overlay').addEventListener('click', e => { if (e.target === $('feedback-overlay')) _closeFeedback(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') _closeFeedback(); }); $('feedback-submit').addEventListener('click', () => { const title = ($('feedback-title-input').value || '').trim() || 'Feature request'; const detail = ($('feedback-body-input').value || '').trim(); const body = detail ? `## What would you like?\n\n${detail}` : `## What would you like?\n\n`; const url = `${FEEDBACK_REPO}/issues/new` + `?labels=enhancement` + `&title=${encodeURIComponent(title)}` + `&body=${encodeURIComponent(body)}`; window.open(url, '_blank', 'noopener'); _closeFeedback(); $('feedback-title-input').value = ''; $('feedback-body-input').value = ''; }); // ── Capture from phone (iOS Shortcut → /capture inbox) ─────────────────────── function _captureToken() { let t = localStorage.getItem('captureToken'); if (!t) { t = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : 'cap-' + Math.random().toString(36).slice(2) + Date.now().toString(36); localStorage.setItem('captureToken', t); } return t; } const _captureSetup = $('capture-setup'); if (_captureSetup) { _captureSetup.addEventListener('toggle', () => { if (_captureSetup.open) $('capture-token').textContent = _captureToken(); }); const _ctc = $('capture-token-copy'); if (_ctc) _ctc.onclick = () => { navigator.clipboard && navigator.clipboard.writeText(_captureToken()); _ctc.textContent = 'Copied'; setTimeout(() => { _ctc.textContent = 'Copy'; }, 1500); }; const _cgl = $('capture-guide-link'); if (_cgl) _cgl.href = FEEDBACK_REPO + '/blob/master/SHORTCUT.md'; } // On load, drain any places captured from the phone and merge them into the // library (server reuses the living-library merge). Only fires if a token exists. async function _pullCaptureInbox() { const token = localStorage.getItem('captureToken'); if (!token) return; const lib = _readSavedLibrary(); const fd = new FormData(); // Always send the field (even empty) — an empty multipart body fails to parse. fd.append('merge_into', (lib && lib.csv) ? lib.csv : ''); let data; try { const res = await fetch('/capture-inbox/' + encodeURIComponent(token), { method: 'POST', body: fd }); if (!res.ok) return; data = await res.json(); } catch { return; } if (!data || data.empty || !data.job_id) return; showTopTab('explore'); showSubTabBar(data.job_id); $('download-btn').onclick = () => { window.location = '/download/' + data.job_id; }; $('download-geojson-btn').onclick = () => { window.location = '/download/' + data.job_id + '/geojson'; }; $('download-csv-btn').onclick = () => { window.location = '/download/' + data.job_id + '/csv'; }; showSubTab('browse'); await loadResults(data.job_id); if (data.added_urls && data.added_urls.length) { const fresh = new Set(data.added_urls); allRows.forEach(r => { if (fresh.has(r.instagram_url)) r._isNew = true; }); applyFilters(); } const n = data.added || 0; const banner = $('new-banner'), txt = $('new-banner-text'); if (banner && txt && n > 0) { txt.textContent = `📲 ${n} place${n === 1 ? '' : 's'} captured from your phone`; banner.style.display = 'block'; const auto = setTimeout(() => { banner.style.display = 'none'; }, 9000); $('new-banner-dismiss').onclick = () => { clearTimeout(auto); banner.style.display = 'none'; }; } if (_clientGeocode) geocodeClientSide(); } // ── Boot ─────────────────────────────────────────────────────────────────── // Offer to restore a previously cached library (no live job → returning visitor), // show the import card's Merge/Replace control when a library exists, and drain // any phone captures waiting in the inbox. maybeShowRestore(); _syncImportMode(); _pullCaptureInbox();