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: add bookmark import ui

+274 -65
+1
data/README.md
··· 110 110 ## Resources 111 111 112 112 ### Shapefiles 113 + 113 114 - https://osmdata.openstreetmap.de/data/water-polygons.html 114 115 115 116 ### geofabrik
+9 -5
data/cli/commands/build-world.ts
··· 15 15 .name('build:world') 16 16 .description( 17 17 'Build a world overview .pmtiles from planet-latest.osm.pbf. ' + 18 - 'Output: www/static/tiles/world_z<maxzoom>.pmtiles', 18 + 'Output: www/static/tiles/world_z<maxzoom>.pmtiles', 19 19 ) 20 20 .option('--maxzoom <zoom:number>', 'Maximum zoom level (5, 7, or 9).', { 21 21 default: 7, ··· 45 45 46 46 try { 47 47 await run('tilemaker', [ 48 - '--input', PLANET_PBF, 49 - '--output', outPath, 50 - '--config', tmpConfig, 51 - '--process', PROCESS_WORLD_LUA, 48 + '--input', 49 + PLANET_PBF, 50 + '--output', 51 + outPath, 52 + '--config', 53 + tmpConfig, 54 + '--process', 55 + PROCESS_WORLD_LUA, 52 56 ]) 53 57 } finally { 54 58 await Deno.remove(tmpConfig).catch(() => {})
+7 -3
data/cli/commands/build.ts
··· 40 40 41 41 async function bboxFromPoly(region: string): Promise<string | null> { 42 42 const parts = region.split('/') 43 - const polyPath = join(POLY_DIR, ...parts.slice(0, -1), `${leafName(region)}.poly`) 43 + const polyPath = join( 44 + POLY_DIR, 45 + ...parts.slice(0, -1), 46 + `${leafName(region)}.poly`, 47 + ) 44 48 try { 45 49 const text = await Deno.readTextFile(polyPath) 46 50 const coords = text.split('\n').flatMap((line) => { ··· 51 55 return isNaN(lon) || isNaN(lat) ? [] : [{ lon, lat }] 52 56 }) 53 57 if (!coords.length) return null 54 - const west = Math.min(...coords.map((c) => c.lon)) 58 + const west = Math.min(...coords.map((c) => c.lon)) 55 59 const south = Math.min(...coords.map((c) => c.lat)) 56 - const east = Math.max(...coords.map((c) => c.lon)) 60 + const east = Math.max(...coords.map((c) => c.lon)) 57 61 const north = Math.max(...coords.map((c) => c.lat)) 58 62 return `${west},${south},${east},${north}` 59 63 } catch { /* no poly file */ }
+10 -2
data/cli/shared/paths.ts
··· 8 8 export const DOCS_DIR = join(CLI_DIR, 'shared') 9 9 export const CONFIG_JSON = join(DOCS_DIR, 'tilemaker', 'config.json') 10 10 export const PROCESS_LUA = join(DOCS_DIR, 'tilemaker', 'process.lua') 11 - export const CONFIG_WORLD_JSON = join(DOCS_DIR, 'tilemaker', 'config.world.json') 12 - export const PROCESS_WORLD_LUA = join(DOCS_DIR, 'tilemaker', 'process.world.lua') 11 + export const CONFIG_WORLD_JSON = join( 12 + DOCS_DIR, 13 + 'tilemaker', 14 + 'config.world.json', 15 + ) 16 + export const PROCESS_WORLD_LUA = join( 17 + DOCS_DIR, 18 + 'tilemaker', 19 + 'process.world.lua', 20 + ) 13 21 export const REGIONS_FILE = join(DOCS_DIR, 'geofabrik', 'regions.txt') 14 22 export const TILES_OUT_DIR = join(ROOT_DIR, 'www', 'static', 'tiles') 15 23 export const TILES_MANIFEST = join(TILES_OUT_DIR, 'tiles.json')
+181 -48
www/routes/bookmarks.ts
··· 2 2 import app from '../models/app.ts' 3 3 import type { Bookmark, BookmarkFolder } from '../models/schema.ts' 4 4 import { 5 + type ImportResult, 6 + parseGpx, 5 7 parseKml, 6 8 parseKmz, 7 - parseGpx, 8 9 } from '../utils/import_bookmarks.ts' 9 10 import { setMapNav } from '../utils/nav.ts' 10 11 ··· 15 16 private showAddBookmark = false 16 17 private showAddFolder = false 17 18 private editingBookmark: Bookmark | null = null 18 - private importStatus: string | null = null 19 + private pendingImport: ImportResult | null = null 20 + private importError: string | null = null 19 21 20 22 protected override createRenderRoot() { 21 23 return this ··· 90 92 } 91 93 92 94 async #handleImport(e: Event) { 93 - const file = (e.target as HTMLInputElement).files?.[0] 95 + const input = e.target as HTMLInputElement 96 + const file = input.files?.[0] 94 97 if (!file) return 95 - ;(e.target as HTMLInputElement).value = '' 98 + input.value = '' 99 + this.importError = null 96 100 try { 97 - let result 98 101 if (file.name.endsWith('.kmz')) { 99 - result = await parseKmz(await file.arrayBuffer()) 102 + this.pendingImport = await parseKmz(await file.arrayBuffer()) 100 103 } else if (file.name.endsWith('.gpx')) { 101 - result = parseGpx(await file.text()) 104 + this.pendingImport = parseGpx(await file.text()) 102 105 } else { 103 - result = parseKml(await file.text()) 104 - } 105 - 106 - const folderIdMap = new Map<string, string>() 107 - for (const folder of result.folders) { 108 - await app.addFolder(folder.name) 109 - const created = app.bookmarkFolders.find((f) => f.name === folder.name) 110 - if (created) folderIdMap.set(folder.tempId, created.id) 111 - } 112 - for (const bm of result.bookmarks) { 113 - const folderId = bm.folderTempId 114 - ? (folderIdMap.get(bm.folderTempId) ?? null) 115 - : null 116 - await app.addBookmark(bm.name, bm.lat, bm.lng, 12, folderId, bm.address) 117 - } 118 - 119 - const parts = [] 120 - if (result.bookmarks.length > 0) { 121 - parts.push( 122 - `${result.bookmarks.length} bookmark${result.bookmarks.length === 1 ? '' : 's'}`, 123 - ) 106 + this.pendingImport = parseKml(await file.text()) 124 107 } 125 - if (result.folders.length > 0) { 126 - parts.push( 127 - `${result.folders.length} folder${result.folders.length === 1 ? '' : 's'}`, 108 + const existing = app.bookmarks 109 + for (const bm of this.pendingImport.bookmarks) { 110 + bm.isDuplicate = existing.some( 111 + (e) => 112 + Math.abs(e.lat - bm.lat) < 0.0001 && 113 + Math.abs(e.lng - bm.lng) < 0.0001, 128 114 ) 129 115 } 130 - this.importStatus = parts.length 131 - ? `Imported ${parts.join(' and ')}.` 132 - : 'Nothing to import.' 133 116 } catch (err) { 134 - this.importStatus = `Import failed: ${err instanceof Error ? err.message : 'unknown error'}` 117 + this.importError = err instanceof Error 118 + ? err.message 119 + : 'Failed to parse file' 135 120 } 136 121 this.requestUpdate() 137 122 } 138 123 124 + async #confirmImport() { 125 + if (!this.pendingImport) return 126 + const { folders, bookmarks } = this.pendingImport 127 + this.pendingImport = null 128 + this.requestUpdate() 129 + const folderIdMap = new Map<string, string>() 130 + for (const folder of folders) { 131 + await app.addFolder(folder.name) 132 + const created = app.bookmarkFolders.find((f) => f.name === folder.name) 133 + if (created) folderIdMap.set(folder.tempId, created.id) 134 + } 135 + for (const bm of bookmarks) { 136 + if (bm.isDuplicate) continue 137 + const folderId = bm.folderTempId 138 + ? (folderIdMap.get(bm.folderTempId) ?? null) 139 + : null 140 + await app.addBookmark(bm.name, bm.lat, bm.lng, 12, folderId, bm.address) 141 + } 142 + } 143 + 139 144 async #deleteFolder(id: string) { 140 145 await app.deleteFolder(id) 141 146 this.expandedFolders.delete(id) ··· 193 198 > 194 199 + Bookmark 195 200 </button> 196 - <label class="bm-import-label"> 201 + <button 202 + @click="${() => 203 + this.renderRoot.querySelector<HTMLInputElement>('.bm-file-input') 204 + ?.click()}" 205 + > 197 206 Import 198 - <input 199 - type="file" 200 - accept=".kml,.kmz,.gpx" 201 - style="display:none" 202 - @change="${this.#handleImport}" 203 - > 204 - </label> 207 + </button> 208 + <input 209 + class="bm-file-input" 210 + type="file" 211 + accept=".kml,.kmz,.gpx" 212 + style="display:none" 213 + @change="${this.#handleImport}" 214 + > 205 215 </div> 206 - ${this.importStatus 207 - ? html`<p class="bm-import-status">${this.importStatus}</p>` 208 - : ''} 209 - 210 - ${this.showAddFolder 216 + ${this.importError 217 + ? html` 218 + <p class="bm-import-error">${this.importError}</p> 219 + ` 220 + : ''} ${this.showAddFolder 211 221 ? html` 212 222 <form class="bm-add-form" @submit="${this.#submitAddFolder}"> 213 223 <input ··· 337 347 </form> 338 348 ` 339 349 : ''} 350 + 351 + <ui-dialog 352 + ?open="${this.pendingImport !== null}" 353 + @dismiss="${() => { 354 + this.pendingImport = null 355 + this.requestUpdate() 356 + }}" 357 + > 358 + <dialog> 359 + <article class="bm-dialog"> 360 + ${this.pendingImport 361 + ? html` 362 + <h2 class="bm-dialog-title">Import Preview</h2> 363 + <p class="bm-import-summary"> 364 + ${(() => { 365 + const dupes = this.pendingImport!.bookmarks.filter( 366 + (b) => b.isDuplicate, 367 + ).length 368 + const fresh = this.pendingImport!.bookmarks.length - dupes 369 + const folderCount = this.pendingImport!.folders.length 370 + const parts = [] 371 + if (fresh > 0) { 372 + parts.push( 373 + `${fresh} bookmark${fresh === 1 ? '' : 's'} to import`, 374 + ) 375 + } 376 + if (dupes > 0) { 377 + parts.push( 378 + `${dupes} duplicate${ 379 + dupes === 1 ? '' : 's' 380 + } will be skipped`, 381 + ) 382 + } 383 + if (folderCount > 0) { 384 + parts.push( 385 + `${folderCount} folder${folderCount === 1 ? '' : 's'}`, 386 + ) 387 + } 388 + return parts.join(' · ') || 'Nothing to import' 389 + })()} 390 + </p> 391 + <div class="bm-import-preview"> 392 + ${this.pendingImport.folders.map((folder) => { 393 + const items = this.pendingImport!.bookmarks.filter( 394 + (b) => b.folderTempId === folder.tempId, 395 + ) 396 + return html` 397 + <div class="bm-import-group"> 398 + <p class="bm-import-group-name">${folder.name}</p> 399 + ${items.map( 400 + (b) => 401 + html` 402 + <div 403 + class="bm-import-item${b.isDuplicate 404 + ? ' bm-import-item--duplicate' 405 + : ''}" 406 + > 407 + <span class="bm-item-name">${b.name}</span> 408 + <span class="bm-item-coords">${b.lat.toFixed( 409 + 4, 410 + )}, ${b.lng.toFixed(4)}</span> 411 + ${b.isDuplicate 412 + ? html` 413 + <span class="bm-import-dupe-badge">exists</span> 414 + ` 415 + : ''} 416 + </div> 417 + `, 418 + )} 419 + </div> 420 + ` 421 + })} ${this.pendingImport.bookmarks.filter( 422 + (b) => b.folderTempId === null, 423 + ).length > 0 424 + ? html` 425 + <div class="bm-import-group"> 426 + ${this.pendingImport.folders.length > 0 427 + ? html` 428 + <p class="bm-import-group-name">No Category</p> 429 + ` 430 + : ''} ${this.pendingImport.bookmarks 431 + .filter((b) => b.folderTempId === null) 432 + .map( 433 + (b) => 434 + html` 435 + <div 436 + class="bm-import-item${b.isDuplicate 437 + ? ' bm-import-item--duplicate' 438 + : ''}" 439 + > 440 + <span class="bm-item-name">${b.name}</span> 441 + <span class="bm-item-coords">${b.lat.toFixed( 442 + 4, 443 + )}, ${b.lng.toFixed(4)}</span> 444 + ${b.isDuplicate 445 + ? html` 446 + <span class="bm-import-dupe-badge">exists</span> 447 + ` 448 + : ''} 449 + </div> 450 + `, 451 + )} 452 + </div> 453 + ` 454 + : ''} 455 + </div> 456 + <div class="bm-form-actions"> 457 + <button @click="${this.#confirmImport}">Import</button> 458 + <button 459 + type="button" 460 + @click="${() => { 461 + this.pendingImport = null 462 + this.requestUpdate() 463 + }}" 464 + > 465 + Cancel 466 + </button> 467 + </div> 468 + ` 469 + : ''} 470 + </article> 471 + </dialog> 472 + </ui-dialog> 340 473 341 474 <ui-dialog 342 475 ?open="${this.editingBookmark !== null}"
+3 -1
www/routes/map.ts
··· 14 14 // Track which sources have been registered with the protocol across navigations 15 15 const registeredSources = new Set<string>() 16 16 17 - async function fetchTileManifest(): Promise<{ name: string; filename: string }[]> { 17 + async function fetchTileManifest(): Promise< 18 + { name: string; filename: string }[] 19 + > { 18 20 try { 19 21 const res = await fetch('/static/tiles/tiles.json') 20 22 if (!res.ok) return []
+58
www/static/styles/theme.css
··· 578 578 margin: 0; 579 579 font-variant-numeric: tabular-nums; 580 580 } 581 + 582 + /* ── Import confirmation dialog ─────────────────────────────────────────── */ 583 + 584 + .bm-import-error { 585 + font-size: var(--f6); 586 + color: var(--error); 587 + margin: 0 0 var(--s3); 588 + } 589 + 590 + .bm-import-summary { 591 + font-size: var(--f5); 592 + opacity: 0.6; 593 + margin: 0; 594 + } 595 + 596 + .bm-import-preview { 597 + max-height: 50vh; 598 + overflow-y: auto; 599 + border: 1px solid currentColor; 600 + border-radius: var(--br-base); 601 + } 602 + 603 + .bm-import-group + .bm-import-group { 604 + border-top: 1px solid currentColor; 605 + } 606 + 607 + .bm-import-group-name { 608 + font-size: var(--f6); 609 + font-weight: var(--fw-semibold); 610 + opacity: 0.5; 611 + text-transform: uppercase; 612 + letter-spacing: 0.05em; 613 + margin: 0; 614 + padding: var(--s2) var(--s3) var(--s1); 615 + } 616 + 617 + .bm-import-item { 618 + padding: var(--s2) var(--s3); 619 + display: flex; 620 + flex-direction: column; 621 + gap: 1px; 622 + } 623 + 624 + .bm-import-item + .bm-import-item { 625 + border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); 626 + } 627 + 628 + .bm-import-item--duplicate { 629 + opacity: 0.4; 630 + } 631 + 632 + .bm-import-dupe-badge { 633 + font-size: var(--f7); 634 + font-weight: var(--fw-semibold); 635 + text-transform: uppercase; 636 + letter-spacing: 0.05em; 637 + opacity: 0.7; 638 + } 581 639 }
+5 -6
www/utils/import_bookmarks.ts
··· 8 8 lng: number 9 9 folderTempId: string | null 10 10 address?: string 11 + isDuplicate?: boolean 11 12 } 12 13 13 14 export type ImportResult = { ··· 27 28 for (const child of root.children) { 28 29 if (child.localName === 'Folder') { 29 30 const tempId = `f${counter++}` 30 - const name = 31 - child.querySelector('name')?.textContent?.trim() ?? 'Imported Folder' 31 + const name = child.querySelector('name')?.textContent?.trim() ?? 32 + 'Imported Folder' 32 33 folders.push({ tempId, name }) 33 34 // Collect all placemarks inside this folder (nested folders are flattened) 34 35 for (const pm of child.querySelectorAll('Placemark')) { ··· 62 63 63 64 export async function parseKmz(buffer: ArrayBuffer): Promise<ImportResult> { 64 65 const files = unzipSync(new Uint8Array(buffer)) 65 - const entry = 66 - Object.keys(files).find((k) => k === 'doc.kml') ?? 66 + const entry = Object.keys(files).find((k) => k === 'doc.kml') ?? 67 67 Object.keys(files).find((k) => k.endsWith('.kml')) 68 68 if (!entry) throw new Error('No KML file found in KMZ archive') 69 69 return parseKml(new TextDecoder().decode(files[entry])) ··· 82 82 const lat = parseFloat(parts[1]) 83 83 if (isNaN(lat) || isNaN(lng)) return null 84 84 const name = el.querySelector('name')?.textContent?.trim() ?? 'Unnamed' 85 - const address = 86 - el.querySelector('address')?.textContent?.trim() || undefined 85 + const address = el.querySelector('address')?.textContent?.trim() || undefined 87 86 return { name, lat, lng, folderTempId, address } 88 87 }