ig-v1 / web /static /app.js
harshith99's picture
Feat: capture inbox (Phase B) β€” phone captures merge into the library
eda6c91
Raw
History Blame Contribute Delete
71.3 kB
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: '&copy; OpenStreetMap contributors',
}).addTo(_map);
_markerLayer = L.layerGroup().addTo(_map);
}
_markerLayer.clearLayers();
const esc = s => (s == null ? '' : String(s).replace(/[&<>"]/g,
c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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();