1/** 2 * Route Bookmarks â lightweight bookmark references to library routes. 3 * 4 * Stores minimal data in users/{uid}/bookmarks/{routeId}. 5 * Renders bookmarks inside the "Bookmarked Routes" folder within "My Routes". 6 */ 7 8import { store } from '../core/state.js?v=284'; 9import { eventBus } from '../core/event-bus.js?v=284'; 10import { config } from '../core/config.js?v=284'; 11import { escapeHtml } from '../core/utils.js?v=284'; 12import { enterViewMode } from './route-view.js?v=284'; 13 14function getDb() { 15 try { return firebase.firestore(); } catch (e) { return null; } 16} 17 18let bookmarkedIds = new Set(); 19 20export function initBookmarks() { 21 // Load bookmarks when user signs in 22 eventBus.on('auth:changed', ({ user }) => { 23 if (user) { 24 loadBookmarks(); 25 } else { 26 bookmarkedIds.clear(); 27 renderBookmarkList([]); 28 const folder = document.getElementById('my-bookmarked-folder'); 29 if (folder) folder.style.display = 'none'; 30 } 31 }); 32} 33 34/** 35 * Toggle bookmark for a route. Returns true if bookmarked, false if removed. 36 */ 37export async function toggleBookmark(routeId, routeData) { 38 const user = store.get('currentUser'); 39 if (!user) { 40 eventBus.emit('auth:loginRequired'); 41 return null; 42 } 43 44 const db = getDb(); 45 if (!db) return null; 46 47 const ref = db.collection('users').doc(user.uid) 48 .collection('bookmarks').doc(routeId); 49 50 if (bookmarkedIds.has(routeId)) { 51 // Remove bookmark 52 await ref.delete(); 53 bookmarkedIds.delete(routeId); 54 await loadBookmarks(); 55 return false; 56 } else { 57 // Add bookmark 58 let distKm = routeData?.distanceKm || routeData?.distance || 0; 59 if (distKm > 5000) distKm = distKm / 1000; // likely stored in meters 60 const elevUp = routeData?.elevation?.up || 0; 61 62 // Generate preview from the currently-displayed route coordinates 63 const lastRoute = store.get('_lastRouteData'); 64 const previewUrl = lastRoute?.coordinates 65 ? getStaticMapUrl(lastRoute.coordinates) 66 : null; 67 68 const bookmarkData = { 69 routeId, 70 name: routeData?.name || 'Untitled Route', 71 distance: Math.round(distKm * 10) / 10, 72 elevation: Math.round(elevUp), 73 bookmarkedAt: firebase.firestore.FieldValue.serverTimestamp(), 74 }; 75 if (previewUrl) bookmarkData.previewUrl = previewUrl; 76 77 await ref.set(bookmarkData); 78 bookmarkedIds.add(routeId); 79 await loadBookmarks(); 80 return true; 81 } 82} 83 84/** 85 * Check if a route is currently bookmarked. 86 */ 87export function isBookmarked(routeId) { 88 return bookmarkedIds.has(routeId); 89} 90 91/** 92 * Load all bookmarks from Firestore and render the list. 93 */ 94export async function loadBookmarks() { 95 const user = store.get('currentUser'); 96 if (!user) return; 97 98 const db = getDb(); 99 if (!db) return; 100 101 const listEl = document.getElementById('bookmarked-routes-list'); 102 const emptyEl = document.getElementById('bookmarked-routes-empty'); 103 const loadingEl = document.getElementById('bookmarked-routes-loading'); 104 const folder = document.getElementById('my-bookmarked-folder'); 105 106 if (loadingEl) loadingEl.classList.remove('hidden'); 107 if (emptyEl) emptyEl.classList.add('hidden'); 108 if (listEl) listEl.innerHTML = ''; 109 110 try { 111 const snapshot = await db.collection('users').doc(user.uid) 112 .collection('bookmarks') 113 .orderBy('bookmarkedAt', 'desc') 114 .limit(50) 115 .get(); 116 117 if (loadingEl) loadingEl.classList.add('hidden'); 118 119 bookmarkedIds.clear(); 120 const bookmarks = []; 121 snapshot.forEach(doc => { 122 const data = doc.data(); 123 bookmarkedIds.add(doc.id); 124 bookmarks.push({ id: doc.id, ...data }); 125 }); 126 127 renderBookmarkList(bookmarks); 128 129 // Show/hide folder based on whether there are bookmarks 130 if (folder) { 131 folder.style.display = bookmarks.length > 0 ? '' : 'none'; 132 } 133 } catch (err) { 134 console.error('[Bookmarks] Load failed:', err); 135 if (loadingEl) loadingEl.classList.add('hidden'); 136 if (emptyEl) emptyEl.classList.remove('hidden'); 137 } 138} 139 140function renderBookmarkList(bookmarks) { 141 const listEl = document.getElementById('bookmarked-routes-list'); 142 const emptyEl = document.getElementById('bookmarked-routes-empty'); 143 if (!listEl) return; 144 145 if (bookmarks.length === 0) { 146 listEl.innerHTML = ''; 147 if (emptyEl) emptyEl.classList.remove('hidden'); 148 return; 149 } 150 151 if (emptyEl) emptyEl.classList.add('hidden'); 152 listEl.innerHTML = ''; 153 154 for (const bm of bookmarks) { 155 const div = document.createElement('div'); 156 div.className = 'saved-route-item';
157 158 const distStr = bm.distance ? `${bm.distance} km` : ''; 159 const elevStr = bm.elevation ? `â${bm.elevation}m` : ''; 160 let dateStr = ''; 161 if (bm.bookmarkedAt) { 162 const date = bm.bookmarkedAt.toDate ? bm.bookmarkedAt.toDate() : new Date(bm.bookmarkedAt); 163 const day = date.getDate(); 164 const suffix = [, 'st', 'nd', 'rd'][day % 10 > 3 ? 0 : (day % 100 - day % 10 !== 10) * (day % 10)] || 'th'; 165 const month = date.toLocaleDateString('en-AU', { month: 'short' }); 166 const year = date.getFullYear(); 167 dateStr = `${day}${suffix} ${month}, ${year}`; 168 } 169 170 div.innerHTML = ` 171 <div class="saved-route-row"> 172 <div class="saved-route-thumb"${bm.previewUrl ? '' : ' style="display:none"'}> 173 ${bm.previewUrl 174 ? `<img src="${escapeHtml(bm.previewUrl)}" alt="" loading="lazy" onerror="this.parentElement.style.display='none'">` 175 : ''} 176 </div> 177 <div class="saved-route-info"> 178 <div class="saved-route-top"> 179 <div class="saved-route-name">${escapeHtml(bm.name || 'Untitled Route')}</div> 180 <button class="saved-route-delete" title="Remove bookmark"> 181 <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"> 182 <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> 183 </svg> 184 </button> 185 </div> 186 <div class="saved-route-meta"> 187 ${distStr ? `<span>${distStr}</span>` : ''} 188 ${elevStr ? `<span>${elevStr}</span>` : ''} 189 </div> 190 <div class="saved-route-meta"> 191 ${dateStr ? `<span>${dateStr}</span>` : ''} 192 </div> 193 </div> 194 </div> 195 `; 196 197 // Click to open route in view mode 198 div.addEventListener('click', (e) => { 199 if (e.target.closest('.saved-route-delete')) return; 200 enterViewMode(bm.routeId || bm.id); 201 }); 202 203 // Remove bookmark 204 const deleteBtn = div.querySelector('.saved-route-delete'); 205 if (deleteBtn) { 206 deleteBtn.addEventListener('click', async (e) => { 207 e.stopPropagation(); 208 await removeBookmark(bm.id); 209 }); 210 } 211 212 listEl.appendChild(div); 213 } 214} 215 216async function removeBookmark(routeId) { 217 const user = store.get('currentUser'); 218 if (!user) return; 219 220 const db = getDb(); 221 if (!db) return; 222 223 try { 224 await db.collection('users').doc(user.uid) 225 .collection('bookmarks').doc(routeId).delete(); 226 bookmarkedIds.delete(routeId); 227 await loadBookmarks(); 228 229 // If we're currently viewing this route, update the bookmark button 230 if (store.get('viewMode') && store.get('viewRouteId') === routeId) { 231 eventBus.emit('bookmark:changed', { routeId, bookmarked: false }); 232 } 233 } catch (err) { 234 console.error('[Bookmarks] Remove failed:', err); 235 } 236} 237 238 239// âââ Static map preview âââ 240 241function getStaticMapUrl(coordinates) { 242 if (!coordinates || coordinates.length < 2 || !config.mapboxToken) return null; 243 244 const base = `https://api.mapbox.com/styles/v1/mapbox/streets-v12/static/`; 245 const suffix = `/auto/150x150@2x?access_token=${config.mapboxToken}&padding=20,20,20,20`; 246 247 for (const pts of [500, 250]) { 248 const sampled = sampleCoords(coordinates, pts); 249 const enc = encodeURIComponent(encodePolyline(sampled)); 250 if (!enc) continue; 251 const url = `${base}path-7+60a5fa-0.3(${enc}),path-3+3b82f6-0.9(${enc})${suffix}`; 252 if (url.length <= 8000) return url; 253 } 254 return null; 255} 256 257function sampleCoords(coords, maxPoints) { 258 if (coords.length <= maxPoints) return coords; 259 const step = (coords.length - 1) / (maxPoints - 1); 260 const result = []; 261 for (let i = 0; i < maxPoints - 1; i++) { 262 result.push(coords[Math.round(i * step)]); 263 } 264 result.push(coords[coords.length - 1]); 265 return result; 266} 267 268function encodePolyline(coords) { 269 let encoded = ''; 270 let prevLat = 0, prevLng = 0; 271 for (const c of coords) { 272 const lat = Math.round((c[1] || c.lat || 0) * 1e5); 273 const lng = Math.round((c[0] || c.lng || 0) * 1e5); 274 encoded += encodeSignedValue(lat - prevLat); 275 encoded += encodeSignedValue(lng - prevLng); 276 prevLat = lat; 277 prevLng = lng; 278 } 279 return encoded; 280} 281 282function encodeSignedValue(val) { 283 let v = val < 0 ? ~(val << 1) : val << 1; 284 let encoded = ''; 285 while (v >= 0x20) { 286 encoded += String.fromCharCode((0x20 | (v & 0x1f)) + 63); 287 v >>= 5; 288 } 289 encoded += String.fromCharCode(v + 63); 290 return encoded; 291}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.