Create or search a trip
Your Trips
<button class="ttp-btn ttp-primary" id="eSave">Save</button> <button class="ttp-btn" id="eCancel">Cancel</button> </div> `; els.placeList.prepend(container); let newCoords = {lat:p.lat, lng:p.lng}; let newName = p.name; const eQuery = container.querySelector('#eQuery'); const eSuggest = container.querySelector('#eSuggest'); const eLat = container.querySelector('#eLat'); const eLng = container.querySelector('#eLng'); const eStatus = container.querySelector('#eStatus'); const eMap = container.querySelector('#eMap'); function setStatusInline(msg,isErr=false){ eStatus.textContent=msg||''; eStatus.style.color=isErr?'var(--danger)':'var(--muted)'; } function fillInline(lat,lng){ eLat.value = String(lat); eLng.value = String(lng); showSinglePin(eMap, Number(lat), Number(lng), p.visited); eMap.style.display='block'; } const doInlineQuery = debounce(async ()=>{ const q = (eQuery.value||'').trim(); if(!q){ eSuggest.style.display='none'; setStatusInline(''); return; } if (/(^https?:\/\/)?(maps\.app\.goo\.gl|goo\.gl\/maps)/i.test(q)) { setStatusInline('Mobile short Google Maps links are not supported. Use the full google.com/maps link or type a place name.', true); eSuggest.style.display='none'; return; } if(isFullGmapsUrl(q)){ const c = coordsFromGoogleUrl(q); if(c){ const nm = nameFromGoogleUrl(q); if(nm) { newName = nm; eQuery.value = nm; } setStatusInline(`Got coords from Google URL → ${formatLatLng(c.lat,c.lng)}`); newCoords = {lat:c.lat, lng:c.lng}; fillInline(c.lat, c.lng); eSuggest.style.display='none'; return; }else{ setStatusInline('Could not read coordinates from that Google URL.', true); } } try{ setStatusInline('Searching…'); const results = await nominatimSearch(q, 6); setStatusInline(''); eSuggest.innerHTML = ''; if(!results.length){ eSuggest.style.display='none'; return; } results.forEach(item=>{ const name = item.namedetails?.name || item.display_name || `${item.lat},${item.lon}`; const details = [item.type, item.class, item.address?.city||item.address?.town||item.address?.village||item.address?.state, item.address?.country] .filter(Boolean).join(' • '); const div = document.createElement('div'); div.className = 'ttp-suggest-item'; div.innerHTML = `<div class="ttp-suggest-name">${escapeHtml(name)}</div><div class="ttp-suggest-details">${escapeHtml(details)}</div>`; div.addEventListener('click', ()=>{ eSuggest.style.display='none'; newCoords = { lat: parseFloat(item.lat), lng: parseFloat(item.lon) }; newName = name; eQuery.value = name; fillInline(newCoords.lat, newCoords.lng); }); eSuggest.appendChild(div); }); eSuggest.style.display='grid'; }catch(e){ setStatusInline('Search failed.', true); eSuggest.style.display='none'; } }, 350); const doInlineLatLng = debounce(()=>{ const la = parseFloat(eLat.value), lo = parseFloat(eLng.value); if(isFinite(la) && isFinite(lo)){ newCoords = {lat:la, lng:lo}; fillInline(la, lo); setStatusInline(`Using manual coordinates → ${formatLatLng(la,lo)}`); } }, 300); eQuery.addEventListener('input', doInlineQuery); eLat.addEventListener('input', doInlineLatLng); eLng.addEventListener('input', doInlineLatLng); fillInline(p.lat, p.lng); // start with existing container.querySelector('#eSave').addEventListener('click',()=>{ const notes = container.querySelector('#eNotes').value; const finalName = (eQuery.value || newName || 'Place').trim(); updatePlace(tripId, p.id, { name: finalName, notes, lat:newCoords.lat, lng:newCoords.lng }); renderPlaces(); renderAllPlacesMap(); }); container.querySelector('#eCancel').addEventListener('click',()=>renderPlaces());

}

// ---------- Import / Export ---------- function download(filename, text){ const blob = new Blob([text], {type:‘application/json’}); const url = URL.createObjectURL(blob); const a = document.createElement(‘a’); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); setTimeout(()=>{ document.body.removeChild(a); URL.revokeObjectURL(url); }, 0); } function timestamp(){ const d=new Date(); return d.toISOString().replace(/[:.]/g,’-’); }

function exportAll(){ const payload = { version: 1, exportedAt: new Date().toISOString(), data: db }; download(trip-planner-all-${timestamp()}.json, JSON.stringify(payload, null, 2)); } function exportTrip(id){ const t=getTrip(id); if(!t){ alert(‘No trip selected’); return; } const payload = { version: 1, exportedAt: new Date().toISOString(), data: { trip: t } }; download(trip-${t.id}-${slug(t.name)}-${timestamp()}.json, JSON.stringify(payload, null, 2)); }

async function importMerge(obj){ const data = obj?.data ?? obj; if(!data){ alert(‘Invalid file: missing data’); return; }

let trips = [];
if(Array.isArray(data.trips)){ trips = data.trips; }
else if(data.trip){ trips = [data.trip]; }
else if(Array.isArray(obj)){ trips = obj; }
else if(data.name && Array.isArray(data.places)){ trips = [data]; }

if(!Array.isArray(trips) || trips.length===0){ alert('No trips found to import'); return; }

const addedTripIds = [];
for(const incoming of trips){
  const newTrip = {
    id: nextTripId(),
    name: incoming.name || `Imported Trip ${db.lastTripId}`,
    createdAt: Date.now(),
    visited: !!incoming.visited,
    places: []
  };
  const places = Array.isArray(incoming.places) ? incoming.places : [];
  for(const p of places){
    newTrip.places.push({
      id: nextPlaceId(),
      name: p.name || `Imported Place ${db.lastPlaceId}`,
      notes: p.notes || '',
      lat: Number(p.lat) || 0,
      lng: Number(p.lng) || 0,
      visited: !!p.visited,
      createdAt: Date.now()
    });
  }
  db.trips.push(newTrip);
  addedTripIds.push(newTrip.id);
}
saveDB();
renderTrips();
if(addedTripIds.length){ openTrip(addedTripIds[addedTripIds.length-1]); }
alert(`Imported ${addedTripIds.length} trip(s).`);

}

// ---------- Share (unchanged) ---------- function uint8ToBase64(bytes){ let bin=”; const chunk=0x8000; for(let i=0;i<bytes.length;i+=chunk){ bin += String.fromCharCode.apply(null, bytes.subarray(i,i+chunk)); } return btoa(bin); } function base64ToUint8(b64){ const bin=atob(b64); const bytes=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++) bytes[i]=bin.charCodeAt(i); return bytes; } function toBase64Url(bytes){ return uint8ToBase64(bytes).replace(/+/g,’-‘).replace(///g,'').replace(/=+$/,”); } function fromBase64Url(str){ const b64=str.replace(/-/g,’+‘).replace(//g,’/’); const pad=b64.length%4 ? ’=‘.repeat(4-(b64.length%4)) : ”; return base64ToUint8(b64+pad); } async function gzipCompress(str){ try{ if(‘CompressionStream’ in window){ const cs=new CompressionStream(‘gzip’); const stream=new Blob([str]).stream().pipeThrough(cs); const buf=await new Response(stream).arrayBuffer(); return new Uint8Array(buf); } }catch(e){} return new TextEncoder().encode(str); } async function gzipDecompress(bytes){ try{ if(‘DecompressionStream’ in window){ const ds=new DecompressionStream(‘gzip’); const stream=new Blob([bytes]).stream().pipeThrough(ds); return await new Response(stream).text(); } }catch(e){} return new TextDecoder().decode(bytes); } async function makeShareLink(payloadObj){ const json=JSON.stringify(payloadObj); const bytes=await gzipCompress(json); const encoded=toBase64Url(bytes); return { url:${location.origin}${location.pathname}#ttp-share=${encoded}, usedGzip:(‘CompressionStream’ in window), length:encoded.length }; } function showSharePanel(url, note){ els.shareLink.value=url; els.sharePanel.style.display=‘grid’; els.shareNote.textContent=note||”; } function hideSharePanel(){ els.sharePanel.style.display=‘none’; els.shareLink.value=”; els.shareNote.textContent=”; } async function shareCurrentTrip(){ if(!currentTripId){ alert(‘Open a trip first’); return; } const t = getTrip(currentTripId); const payload = { version: 1, exportedAt: new Date().toISOString(), data: { trip: t } }; const { url, usedGzip, length } = await makeShareLink(payload); let note = usedGzip ? ‘Compressed with gzip + Base64. ’ : ‘No gzip support detected — using plain Base64. ’; if(length > 4000) note += Warning: link length is ${length} chars; some apps may truncate long links.; showSharePanel(url, note); } async function tryLoadSharedFromHash(){ const h = location.hash || ”; const prefix = ‘#ttp-share=’; if(!h.startsWith(prefix)) return null; const encoded = h.slice(prefix.length); try{ const bytes = fromBase64Url(encoded); const text = await gzipDecompress(bytes); const obj = JSON.parse(text);

  const data = obj?.data ?? obj;
  let summary = 'A friend sent you a trip.';
  if(data?.trip){
    const name = data.trip.name || 'Untitled';
    const count = Array.isArray(data.trip.places) ? data.trip.places.length : 0;
    summary = `Trip: "${name}" with ${count} place(s).`;
  }else if(Array.isArray(data?.trips)){
    summary = `Dataset with ${data.trips.length} trip(s).`;
  }else if(data?.name && Array.isArray(data?.places)){
    const count = data.places.length;
    summary = `Trip: "${data.name}" with ${count} place(s).`;
  }
  els.shareSummary.textContent = summary;
  els.shareBanner.style.display = 'block';

  els.importSharedNew.onclick = async ()=>{ await importMerge(obj); clearHash(); els.shareBanner.style.display='none'; };
  els.mergeSharedCurrent.onclick = async ()=>{
    const dataIn = obj?.data ?? obj;
    if(!currentTripId){ alert('Open or create a trip first to merge places.'); return; }
    const t = getTrip(currentTripId);
    if(dataIn?.trip && Array.isArray(dataIn.trip.places)){
      dataIn.trip.places.forEach(p=>{
        t.places.push({
          id: nextPlaceId(),
          name: p.name || `Imported Place ${db.lastPlaceId}`,
          notes: p.notes || '',
          lat: Number(p.lat) || 0,
          lng: Number(p.lng) || 0,
          visited: !!p.visited,
          createdAt: Date.now()
        });
      });
      saveDB(); renderPlaces(); renderAllPlacesMap(); clearHash(); els.shareBanner.style.display='none';
    }else{
      alert('Merge is available for a single shared trip. Use "Import as New Trip" for multi-trip data.');
    }
  };
  els.dismissShared.onclick = ()=>{ clearHash(); els.shareBanner.style.display='none'; };
  return obj;
}catch(e){
  console.error('Failed to parse shared data:', e);
  els.shareSummary.textContent = 'Could not read shared content.';
  els.mergeSharedCurrent.style.display = 'none';
  els.importSharedNew.textContent = 'Dismiss';
  els.importSharedNew.onclick = ()=>{ clearHash(); els.shareBanner.style.display='none'; };
  els.shareBanner.style.display = 'block';
  return null;
}

} function clearHash(){ if(history.replaceState){ history.replaceState(null, ”, location.pathname + location.search); } else { location.hash=”; } }

// ---------- Unified create/search ---------- function searchPredefs(q){ if(!Array.isArray(PREDEFINED_TRIPS)) return []; q = (q||”).trim().toLowerCase(); if(!q) return []; const words = q.split(/\s+/).filter(Boolean); const scored = PREDEFINED_TRIPS.map(t=>{ const hay = (t.name + ’ ’ + (t.tags||[]).join(’ ‘)).toLowerCase(); const score = words.reduce((s,w)=> s + (hay.includes(w) ? 1 : 0), 0); return { trip:t, score }; }).filter(x=>x.score>0); scored.sort((a,b)=> b.score - a.score || a.trip.name.localeCompare(b.trip.name)); return scored.slice(0,8).map(x=>x.trip); } function renderUnifiedResults(){ const q = els.quickInput.value.trim(); const results = searchPredefs(q); const box = els.predefResults; box.innerHTML = ”;

if(q){
  const createItem = document.createElement('div');
  createItem.className = 'ttp-predef-item';
  createItem.innerHTML = `
    <div class="ttp-predef-title">➕ Create trip named “${escapeHtml(q)}”</div>
    <div class="ttp-predef-tags">Press Enter or click to create a new trip.</div>
  `;
  createItem.addEventListener('click', ()=>{
    const t = addTrip(q);
    els.quickInput.value = '';
    box.style.display = 'none';
    renderTrips(); openTrip(t.id);
  });
  box.appendChild(createItem);
}

results.forEach(tpl=>{
  const item = document.createElement('div');
  item.className='ttp-predef-item';
  item.innerHTML = `
    <div class="ttp-predef-title">${escapeHtml(tpl.name)}</div>
    <div class="ttp-predef-tags">Tags: ${(tpl.tags||[]).map(t=>`<span>#${escapeHtml(t)}</span>`).join(' ')}</div>
    <div class="ttp-predef-actions">
      <button class="ttp-btn ttp-accent ttp-btn--sm" data-add>➕ Add as New Trip</button>
      <button class="ttp-btn ttp-btn--sm" data-merge>➕ Add Places to Current</button>
    </div>
  `;
  item.querySelector('[data-add]').addEventListener('click', ()=>{
    const newTrip = {
      id: nextTripId(),
      name: tpl.name || 'New Trip',
      createdAt: Date.now(),
      visited: !!tpl.visited,
      places: []
    };
    (tpl.places||[]).forEach(p=>{
      newTrip.places.push({
        id: nextPlaceId(),
        name: p.name,
        notes: p.notes || '',
        lat: Number(p.lat),
        lng: Number(p.lng),
        visited: !!p.visited,
        createdAt: Date.now()
      });
    });
    db.trips.push(newTrip); saveDB();
    els.predefResults.style.display='none'; els.quickInput.value='';
    renderTrips(); openTrip(newTrip.id);
  });
  item.querySelector('[data-merge]').addEventListener('click', ()=>{
    if(!currentTripId){ alert('Open or create a trip first to merge places.'); return; }
    const t = getTrip(currentTripId);
    (tpl.places||[]).forEach(p=>{
      t.places.push({
        id: nextPlaceId(),
        name: p.name,
        notes: p.notes || '',
        lat: Number(p.lat),
        lng: Number(p.lng),
        visited: !!p.visited,
        createdAt: Date.now()
      });
    });
    saveDB();
    els.predefResults.style.display='none';
    renderPlaces(); renderAllPlacesMap();
  });
  box.appendChild(item);
});

box.style.display = (q || results.length) ? 'grid' : 'none';

}

// ---------- Events ---------- els.quickInput.addEventListener(‘keydown’, (e)=>{ if(e.key === ‘Enter’){ e.preventDefault(); const name = els.quickInput.value.trim(); if(!name) return; const t = addTrip(name); els.quickInput.value = ”; els.predefResults.style.display=‘none’; renderTrips(); openTrip(t.id); } }); els.quickInput.addEventListener(‘input’, debounce(renderUnifiedResults, 150));

els.tripNameInput.addEventListener(‘input’, debounce(()=>{ if(currentTripId){ const name = els.tripNameInput.value.trim(); updateTripName(currentTripId, name || Trip ${currentTripId}); renderTrips(); } }, 300));

els.tripVisitedBtn.addEventListener(‘click’, ()=>{ if(!currentTripId) return; const t = getTrip(currentTripId); t.visited = !t.visited; saveDB(); setTripVisitedButton(t.visited); renderTrips(); });

els.deleteTripBtn.addEventListener(‘click’, ()=>{ if(!currentTripId) return; const t=getTrip(currentTripId); if(!t) return; if(confirm(Delete "${t.name}" and all its places?)){ deleteTrip(currentTripId); currentTripId=null; els.tripView.style.display=‘none’; els.emptyState.style.display=‘block’; renderTrips(); } });

// Starter import/export els.exportAllBtn.addEventListener(‘click’, exportAll); els.exportTripBtn.addEventListener(‘click’, ()=> currentTripId ? exportTrip(currentTripId) : alert(‘Open a trip first’)); els.importBtn.addEventListener(‘click’, ()=> els.importFile.click()); els.importFile.addEventListener(‘change’, async (e)=>{ const file = e.target.files?.[0]; if(!file) return; try{ const text = await file.text(); const json = JSON.parse(text); await importMerge(json); }catch(err){ console.error(err); alert(‘Could not import this file. Make sure it is a JSON export from this app.’); }finally{ e.target.value = ”; } });

// Share els.shareTripBtn.addEventListener(‘click’, shareCurrentTrip); els.copyShare.addEventListener(‘click’, async ()=>{ try{ await navigator.clipboard.writeText(els.shareLink.value); els.shareNote.textContent = (els.shareNote.textContent||”) + ’ Copied!’; } catch(e){ els.shareNote.textContent = ‘Could not auto-copy. Select and copy manually.’; } }); els.closeShare.addEventListener(‘click’, ()=>{ els.sharePanel.style.display=‘none’; els.shareLink.value=”; els.shareNote.textContent=”; });

// Add place: single field + lat/lng els.placeQuery.addEventListener(‘input’, handlePlaceQuery); els.latInput.addEventListener(‘input’, handleManualLatLng); els.lngInput.addEventListener(‘input’, handleManualLatLng);

els.addPlaceBtn.addEventListener(‘click’, ()=>{ if(!currentTripId) return; const name = (els.placeQuery.value || ‘New Place’).trim(); const notes = els.placeNotes.value; const lat = parseFloat(els.latInput.value); const lng = parseFloat(els.lngInput.value); if(!isFinite(lat) || !isFinite(lng)){ alert(‘Provide coordinates: type a name (pick from suggestions) or paste a full Google Maps URL, or enter LAT/LNG manually.’); return; } addPlace(currentTripId, { name, notes, lat, lng, visited:false });

// reset
els.placeNotes.value='';
els.placeQuery.value=''; els.latInput.value=''; els.lngInput.value='';
els.suggestBox.style.display='none'; els.previewMap.style.display='none';
if(previewLeaflet && previewLeaflet.remove) previewLeaflet.remove(); previewLeaflet=null;

renderPlaces(); renderTrips(); renderAllPlacesMap();
flashHighlight(lat, lng);

});

// Map filters els.filterVisited.addEventListener(‘change’, renderAllPlacesMap); els.filterUnvisited.addEventListener(‘change’, renderAllPlacesMap);

// ---------- Init ---------- async function init(){ loadDB(); renderTrips(); if(db.trips.length===0){ els.emptyState.style.display=‘block’; }else{ const latest=[…db.trips].sort((a,b)=>b.createdAt-a.createdAt)[0]; openTrip(latest.id); } await tryLoadSharedFromHash(); } init(); })();