}
// ---------- 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(); })();
