Spaces:
Sleeping
Sleeping
| 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 ? ` Β· ~<strong>$${lastJobCost}</strong>` : ''; | |
| strip.style.display = 'block'; | |
| strip.innerHTML = `<strong>${total}</strong> places Β· <strong>${pinned}</strong> 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 <strong>${n}</strong> place${n === 1 ? '' : 's'} β none matched your existing library. ` | |
| + `<a href="#" id="merge-replace-link">Replace instead?</a>`; | |
| } 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 = '<span class="spinner"></span>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 = '<span class="spinner"></span>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 = '<span class="spinner"></span>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 = '<span class="spinner"></span>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 = '<span class="spinner"></span>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 = '<span class="spinner"></span>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 = '<span class="dice">π²</span><span>Spin the wheel</span>'; | |
| $('spin-btn').disabled = false; | |
| return; | |
| } | |
| $('spin-btn').innerHTML = '<span class="dice">π²</span><span>Spin again</span>'; | |
| $('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 = '<option value="">All countries</option>'; | |
| countries.forEach(c => { const o = document.createElement('option'); o.value = o.textContent = c; cEl.appendChild(o); }); | |
| const qEl = $('filter-cuisine'); | |
| qEl.innerHTML = '<option value="">All cuisines</option>'; | |
| 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 | |
| ? `<div style="margin-top:4px;"><a href="${esc(r.instagram_url)}" target="_blank" rel="noopener">View reel β</a></div>` : ''; | |
| const html = | |
| `<div class="pp-name">${esc(val(r.name) || 'Unknown place')}</div>` | |
| + (meta ? `<div class="pp-meta">${esc(meta)}</div>` : '') | |
| + (loc ? `<div class="pp-meta">${esc(loc)}</div>` : '') | |
| + (hl ? `<div>β ${esc(hl)}</div>` : '') | |
| + 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 | |
| ? ` Β· <strong>${missing}</strong> 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 = '<span class="spinner"></span>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<!-- Describe the feature and the problem it solves -->`; | |
| 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(); | |