Offline-capable geomap, meant for storing location bookmarks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: updates for bookmarks

+234 -36
+10 -1
www/models/app.ts
··· 48 48 lng: number, 49 49 zoom: number, 50 50 folderId: string | null = null, 51 + address?: string, 51 52 ): Promise<void> { 52 - await this.store.addBookmark(name, lat, lng, zoom, folderId) 53 + await this.store.addBookmark(name, lat, lng, zoom, folderId, address) 53 54 this.notify() 54 55 } 55 56 ··· 60 61 61 62 async moveBookmark(id: string, folderId: string | null): Promise<void> { 62 63 await this.store.moveBookmark(id, folderId) 64 + this.notify() 65 + } 66 + 67 + async updateBookmark( 68 + id: string, 69 + updates: Partial<Pick<Bookmark, 'name' | 'address' | 'folderId'>>, 70 + ): Promise<void> { 71 + await this.store.updateBookmark(id, updates) 63 72 this.notify() 64 73 } 65 74
+1
www/models/schema/v0.ts
··· 9 9 export const Bookmark = z.object({ 10 10 id: z.string(), 11 11 name: z.string(), 12 + address: z.string().optional(), 12 13 lat: z.number(), 13 14 lng: z.number(), 14 15 zoom: z.number().default(12),
+13
www/models/store.ts
··· 72 72 lng: number, 73 73 zoom: number, 74 74 folderId: string | null = null, 75 + address?: string, 75 76 ): Promise<void> { 76 77 const data = await this.#sync.get() 77 78 data.bookmarks = [ ··· 79 80 Bookmark.parse({ 80 81 id: crypto.randomUUID(), 81 82 name, 83 + address, 82 84 lat, 83 85 lng, 84 86 zoom, ··· 100 102 const idx = (data.bookmarks ?? []).findIndex((b) => b.id === id) 101 103 if (idx === -1) return 102 104 data.bookmarks[idx] = { ...data.bookmarks[idx], folderId } 105 + await this.#sync.set(data) 106 + } 107 + 108 + async updateBookmark( 109 + id: string, 110 + updates: Partial<Pick<Bookmark, 'name' | 'address' | 'folderId'>>, 111 + ): Promise<void> { 112 + const data = await this.#sync.get() 113 + const idx = (data.bookmarks ?? []).findIndex((b) => b.id === id) 114 + if (idx === -1) return 115 + data.bookmarks[idx] = { ...data.bookmarks[idx], ...updates } 103 116 await this.#sync.set(data) 104 117 } 105 118
+116 -28
www/routes/bookmarks.ts
··· 9 9 private expandedFolders = new Set<string>() 10 10 private showAddBookmark = false 11 11 private showAddFolder = false 12 + private editingBookmark: Bookmark | null = null 12 13 13 14 protected override createRenderRoot() { 14 15 return this ··· 57 58 await app.moveBookmark(id, folderId) 58 59 } 59 60 61 + async #submitEditBookmark(e: Event) { 62 + e.preventDefault() 63 + if (!this.editingBookmark) return 64 + const form = e.target as HTMLFormElement 65 + const fd = new FormData(form) 66 + const name = (fd.get('name') as string).trim() 67 + const address = (fd.get('address') as string).trim() || undefined 68 + const folderId = (fd.get('folderId') as string) || null 69 + if (!name) return 70 + await app.updateBookmark(this.editingBookmark.id, { 71 + name, 72 + address, 73 + folderId, 74 + }) 75 + this.editingBookmark = null 76 + this.requestUpdate() 77 + } 78 + 79 + async #deleteEditingBookmark() { 80 + if (!this.editingBookmark) return 81 + await app.deleteBookmark(this.editingBookmark.id) 82 + this.editingBookmark = null 83 + this.requestUpdate() 84 + } 85 + 60 86 async #deleteFolder(id: string) { 61 87 await app.deleteFolder(id) 62 88 this.expandedFolders.delete(id) ··· 189 215 <div class="bm-section"> 190 216 ${hasFolders 191 217 ? html` 192 - <h3 class="bm-section-title">Unfiled</h3> 218 + <h3 class="bm-section-title">No Category</h3> 193 219 ` 194 220 : ''} ${unfiled.map((b) => this.#renderBookmark(b))} 195 221 </div> ··· 222 248 ${hasFolders 223 249 ? html` 224 250 <select name="folderId"> 225 - <option value="">Unfiled</option> 251 + <option value="">No Category</option> 226 252 ${this.folders.map((f) => 227 253 html` 228 254 <option value="${f.id}">${f.name}</option> ··· 246 272 </form> 247 273 ` 248 274 : ''} 275 + 276 + <ui-dialog 277 + ?open="${this.editingBookmark !== null}" 278 + @dismiss="${() => { 279 + this.editingBookmark = null 280 + this.requestUpdate() 281 + }}" 282 + > 283 + <dialog> 284 + <article class="bm-dialog"> 285 + ${this.editingBookmark 286 + ? html` 287 + <h2 class="bm-dialog-title">Edit Bookmark</h2> 288 + <form @submit="${this.#submitEditBookmark}"> 289 + <input 290 + name="name" 291 + type="text" 292 + placeholder="Name" 293 + .value="${this.editingBookmark.name}" 294 + required 295 + autocomplete="off" 296 + autofocus 297 + > 298 + <textarea 299 + name="address" 300 + placeholder="Address" 301 + rows="3" 302 + autocomplete="off" 303 + .value="${this.editingBookmark.address ?? ''}" 304 + ></textarea> 305 + <p class="bm-dialog-coords">${this.editingBookmark.lat 306 + .toFixed(5)}, ${this.editingBookmark.lng.toFixed( 307 + 5, 308 + )} · zoom ${this.editingBookmark.zoom.toFixed(1)}</p> 309 + ${this.folders.length > 0 310 + ? html` 311 + <select name="folderId"> 312 + <option 313 + value="" 314 + ?selected="${this.editingBookmark.folderId === null}" 315 + > 316 + No Category 317 + </option> 318 + ${this.folders.map((f) => 319 + html` 320 + <option 321 + value="${f.id}" 322 + ?selected="${this.editingBookmark!.folderId === 323 + f.id}" 324 + > 325 + ${f.name} 326 + </option> 327 + ` 328 + )} 329 + </select> 330 + ` 331 + : ''} 332 + <div class="bm-form-actions"> 333 + <button type="submit">Save</button> 334 + <button 335 + type="button" 336 + @click="${() => { 337 + this.editingBookmark = null 338 + this.requestUpdate() 339 + }}" 340 + > 341 + Cancel 342 + </button> 343 + <button 344 + type="button" 345 + class="bm-btn-danger" 346 + @click="${this.#deleteEditingBookmark}" 347 + > 348 + Delete 349 + </button> 350 + </div> 351 + </form> 352 + ` 353 + : ''} 354 + </article> 355 + </dialog> 356 + </ui-dialog> 249 357 ` 250 358 } 251 359 ··· 259 367 )}</span> 260 368 </div> 261 369 <div class="bm-item-actions"> 262 - ${this.folders.length > 0 263 - ? html` 264 - <select 265 - class="bm-item-folder" 266 - aria-label="Move to folder" 267 - @change="${(e: Event) => 268 - this.#moveBookmark( 269 - b.id, 270 - (e.target as HTMLSelectElement).value || null, 271 - )}" 272 - > 273 - <option value="" ?selected="${b.folderId === 274 - null}">Unfiled</option> 275 - ${this.folders.map((f) => 276 - html` 277 - <option value="${f 278 - .id}" ?selected="${b.folderId === f.id}">${f 279 - .name}</option> 280 - ` 281 - )} 282 - </select> 283 - ` 284 - : ''} 285 370 <button 286 371 class="bm-icon-btn" 287 372 aria-label="Go to location" ··· 291 376 </button> 292 377 <button 293 378 class="bm-icon-btn" 294 - aria-label="Delete bookmark" 295 - @click="${() => this.#deleteBookmark(b.id)}" 379 + aria-label="Edit bookmark" 380 + @click="${() => { 381 + this.editingBookmark = b 382 + this.requestUpdate() 383 + }}" 296 384 > 297 - <img src="/static/icons/x.svg" alt="" aria-hidden="true"> 385 + <img src="/static/icons/edit.svg" alt="" aria-hidden="true"> 298 386 </button> 299 387 </div> 300 388 </div>
+27 -6
www/routes/map.ts
··· 92 92 target.lat, 93 93 target.lng, 94 94 target.zoom, 95 + target.address, 95 96 ), 96 97 ) 97 98 .addTo(this.#map!) ··· 129 130 lat: number, 130 131 lng: number, 131 132 zoom: number, 133 + address?: string, 132 134 ): maplibregl.Popup { 133 135 const container = document.createElement('div') 136 + container.style.cssText = 137 + 'display:flex;flex-direction:column;gap:6px;min-width:180px' 134 138 135 - const label = document.createElement('p') 136 - label.style.cssText = 137 - 'margin:0 0 8px;font-size:0.85em;max-width:200px;word-break:break-word' 138 - label.textContent = name 139 + if (address) { 140 + const addr = document.createElement('p') 141 + addr.style.cssText = 142 + 'margin:0;font-size:0.75em;opacity:0.6;word-break:break-word;max-width:220px' 143 + addr.textContent = address 144 + container.append(addr) 145 + } 146 + 147 + const input = document.createElement('input') 148 + input.type = 'text' 149 + input.value = name 150 + input.placeholder = 'Bookmark name' 151 + input.style.cssText = 'display:block;width:100%;box-sizing:border-box' 139 152 140 153 const button = document.createElement('button') 141 154 button.textContent = 'Add to bookmarks' 155 + button.style.cssText = 'width:100%' 142 156 143 157 button.addEventListener('click', async () => { 144 158 button.disabled = true 145 - await app.addBookmark(name, lat, lng, zoom) 159 + await app.addBookmark( 160 + input.value.trim() || name, 161 + lat, 162 + lng, 163 + zoom, 164 + null, 165 + address, 166 + ) 146 167 button.textContent = 'Saved!' 147 168 }) 148 169 149 - container.append(label, button) 170 + container.append(input, button) 150 171 return new maplibregl.Popup({ offset: 25 }).setDOMContent(container) 151 172 } 152 173
+3 -1
www/routes/search.ts
··· 95 95 } 96 96 97 97 #handleResultClick = (result: NominatimResult) => { 98 + const shortName = result.display_name.split(',')[0].trim() 98 99 setMapNav({ 99 100 lat: parseFloat(result.lat), 100 101 lng: parseFloat(result.lon), 101 102 zoom: zoomFromBoundingbox(result.boundingbox), 102 103 marker: true, 103 - name: result.display_name, 104 + name: shortName, 105 + address: result.display_name, 104 106 }) 105 107 location.hash = '#!/' 106 108 }
+17
www/static/icons/edit.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + > 12 + <path 13 + d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" 14 + ></path><path 15 + d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" 16 + ></path> 17 + </svg>
+45
www/static/styles/theme.css
··· 509 509 display: flex; 510 510 gap: var(--s2); 511 511 } 512 + 513 + .bm-btn-danger { 514 + margin-left: auto; 515 + color: var(--error); 516 + } 517 + 518 + /* ── Bookmark edit dialog ───────────────────────────────────────────────── */ 519 + 520 + .bm-dialog { 521 + padding: var(--s4); 522 + min-width: min(320px, 90vw); 523 + display: flex; 524 + flex-direction: column; 525 + gap: var(--s3); 526 + } 527 + 528 + .bm-dialog-title { 529 + font-size: var(--f4); 530 + font-weight: var(--fw-semibold); 531 + margin: 0; 532 + } 533 + 534 + .bm-dialog form { 535 + display: flex; 536 + flex-direction: column; 537 + gap: var(--s3); 538 + } 539 + 540 + .bm-dialog .bm-form-actions { 541 + margin-top: var(--s1); 542 + } 543 + 544 + .bm-dialog textarea { 545 + resize: vertical; 546 + min-height: 60px; 547 + font-family: inherit; 548 + font-size: var(--f5); 549 + } 550 + 551 + .bm-dialog-coords { 552 + font-size: var(--f6); 553 + opacity: 0.5; 554 + margin: 0; 555 + font-variant-numeric: tabular-nums; 556 + } 512 557 }
+1
www/utils/geocode.ts
··· 4 4 export type DirectCoords = { lat: number; lng: number; zoom: number } 5 5 6 6 export function coordsFromDirectInput(query: string): DirectCoords | null { 7 + console.log(coordsFromPlusCode(query)) 7 8 return coordsFromGeoUri(query) ?? coordsFromPlusCode(query) ?? 8 9 coordsFromMapUrl(query) 9 10 }
+1
www/utils/nav.ts
··· 4 4 zoom: number 5 5 marker?: boolean 6 6 name?: string 7 + address?: string 7 8 } 8 9 9 10 let pending: MapTarget | null = null