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: adds civility/sync and image support

+420 -44
+6 -2
deno.json
··· 20 20 "semiColons": false 21 21 }, 22 22 "imports": { 23 - "@civility/store": "jsr:@civility/store@^1.0.0-beta.5", 24 - "@civility/ui": "jsr:@civility/ui@^0.2.6", 23 + "@civility/blobs": "jsr:@civility/blobs@^1.0.0-beta.1", 24 + "@civility/blobs/idb": "jsr:@civility/blobs@^1.0.0-beta.1/idb", 25 + "@civility/store": "jsr:@civility/store@^1.0.0-beta.6", 26 + "@civility/store/idb": "jsr:@civility/store@^1.0.0-beta.6/idb", 27 + "@civility/sync": "jsr:@civility/sync@^1.0.0-beta.7", 28 + "@civility/ui": "jsr:@civility/ui@^1.0.0-beta.1", 25 29 "@civility/workers": "jsr:@civility/workers@^0.2.4", 26 30 "@zod/zod": "jsr:@zod/zod@^4.3.6", 27 31 "fflate": "npm:fflate@^0.8.2",
+20 -12
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 - "jsr:@civility/store@^1.0.0-beta.5": "1.0.0-beta.5", 5 - "jsr:@civility/ui@~0.2.6": "0.2.9", 4 + "jsr:@civility/blobs@^1.0.0-beta.1": "1.0.0-beta.4", 5 + "jsr:@civility/blobs@^1.0.0-beta.4": "1.0.0-beta.4", 6 + "jsr:@civility/store@^1.0.0-beta.6": "1.0.0-beta.6", 7 + "jsr:@civility/sync@^1.0.0-beta.7": "1.0.0-beta.7", 8 + "jsr:@civility/ui@^1.0.0-beta.1": "1.0.0-beta.1", 6 9 "jsr:@civility/workers@~0.2.4": "0.2.5", 7 10 "jsr:@cliffy/command@1": "1.0.0", 8 11 "jsr:@cliffy/flags@1.0.0": "1.0.0", ··· 26 29 "npm:pmtiles@^4.4.0": "4.4.0" 27 30 }, 28 31 "jsr": { 29 - "@civility/store@1.0.0-beta.5": { 30 - "integrity": "afb3c70da4d4242faf9ca07e54b269889f2b3bac4e23962272c1350a3062a54d", 32 + "@civility/blobs@1.0.0-beta.4": { 33 + "integrity": "6806eb2a5b02e9e611385107b539abe0b2fe8e17066cfc42eaf467e301a6afa0" 34 + }, 35 + "@civility/store@1.0.0-beta.6": { 36 + "integrity": "92226d6e669fd90da7dac1da9f60c63587fb194df1d59a34791f3b827c000383", 31 37 "dependencies": [ 32 38 "jsr:@std/semver", 33 39 "jsr:@std/ulid", 34 40 "npm:fast-json-patch" 35 41 ] 36 42 }, 37 - "@civility/ui@0.2.6": { 38 - "integrity": "62e955a70507c708ce03afa845f1421450ea27c28b7d2bfd7753c27ec49680ab", 43 + "@civility/sync@1.0.0-beta.7": { 44 + "integrity": "2997902549d7fbe6810c208efa8cd79c852192ca1c36cad90dc6fc1f27a6cf47", 39 45 "dependencies": [ 40 - "jsr:@std/html", 41 - "npm:lit" 46 + "jsr:@civility/blobs@^1.0.0-beta.4", 47 + "jsr:@civility/store" 42 48 ] 43 49 }, 44 - "@civility/ui@0.2.9": { 45 - "integrity": "68eff67028c540669f30d5c5d434182ad3686a4610a700137c341d4202d1f996", 50 + "@civility/ui@1.0.0-beta.1": { 51 + "integrity": "34baed127597c084f0326649de1f9d0d025d5cf91ec81a35e3048c8919e01eb2", 46 52 "dependencies": [ 47 53 "jsr:@std/html", 48 54 "npm:lit" ··· 456 462 }, 457 463 "workspace": { 458 464 "dependencies": [ 459 - "jsr:@civility/store@^1.0.0-beta.5", 460 - "jsr:@civility/ui@~0.2.6", 465 + "jsr:@civility/blobs@^1.0.0-beta.1", 466 + "jsr:@civility/store@^1.0.0-beta.6", 467 + "jsr:@civility/sync@^1.0.0-beta.7", 468 + "jsr:@civility/ui@^1.0.0-beta.1", 461 469 "jsr:@civility/workers@~0.2.4", 462 470 "jsr:@zod/zod@^4.3.6", 463 471 "npm:fflate@~0.8.2",
+114
docs/layers.md
··· 1 + # Map Layers 2 + 3 + This document describes the different map layers used in the app and how they are ordered. 4 + 5 + ## Layer Stack (bottom to top) 6 + 7 + ### 1. World Basemap Layers (`world_layers.ts`) 8 + 9 + Low-resolution global basemap for zoom levels 0-5. Loaded from PMTiles. 10 + 11 + | Layer ID | Type | Source Layer | Description | 12 + | ------------------- | ------ | ------------ | ------------------------------------------- | 13 + | `water` | fill | water | Ocean and inland water polygons | 14 + | `landcover-grass` | fill | landcover | Grass areas | 15 + | `landcover-wood` | fill | landcover | Forest/woodland | 16 + | `landcover-wetland` | fill | landcover | Wetland | 17 + | `landcover-sand` | fill | landcover | Sand/desert | 18 + | `landcover-rock` | fill | landcover | Rocky areas | 19 + | `landcover-ice` | fill | landcover | Ice caps/glaciers | 20 + | `park` | fill | park | National parks/nature reserves | 21 + | `waterway` | line | waterway | Named rivers | 22 + | `boundary-state` | line | boundary | State/province boundaries (admin_level 3-6) | 23 + | `boundary-country` | line | boundary | Country boundaries (admin_level 2) | 24 + | `place-continent` | symbol | place | Continent labels | 25 + | `place-country` | symbol | place | Country labels | 26 + | `place-state` | symbol | place | State/province labels | 27 + | `place-city` | symbol | place | City labels | 28 + | `place-town` | symbol | place | Town labels | 29 + | `place-village` | symbol | place | Village labels | 30 + 31 + ### 2. Regional/Detail Layers (`layers.ts`) 32 + 33 + Higher resolution tiles loaded on demand (PMTiles). These are prefixed with the source ID (e.g., `europe-water`, `us-landuse`). 34 + 35 + | Layer ID | Type | Source Layer | Description | 36 + | --------------------------------- | ------ | ------------------- | -------------------------- | 37 + | `*-landuse-residential` | fill | landuse | Residential areas | 38 + | `*-landcover_grass` | fill | landcover | Grass | 39 + | `*-landcover_wood` | fill | landcover | Forest | 40 + | `*-water` | fill | water | Water polygons | 41 + | `*-water_intermittent` | fill | water | Intermittent water | 42 + | `*-landcover-ice-shelf` | fill | landcover | Ice shelves | 43 + | `*-landcover-glacier` | fill | landcover | Glaciers | 44 + | `*-landcover_sand` | fill | landcover | Sand | 45 + | `*-landuse` | fill | landuse | Agriculture | 46 + | `*-landuse_overlay_national_park` | fill | landcover | National parks | 47 + | `*-waterway-tunnel` | line | waterway | Tunnels | 48 + | `*-waterway` | line | waterway | Rivers | 49 + | `*-waterway_intermittent` | line | waterway | Intermittent rivers | 50 + | `*-tunnel_railway_transit` | line | transportation | Railway tunnels | 51 + | `*-building` | fill | building | Buildings | 52 + | `*-housenumber` | symbol | housenumber | House numbers | 53 + | `*-road_area_pier` | fill | transportation | Piers | 54 + | `*-road_pier` | line | transportation | Pier lines | 55 + | `*-road_bridge_area` | fill | transportation | Bridge areas | 56 + | `*-road_path` | line | transportation | Paths/tracks | 57 + | `*-road_minor` | line | transportation | Minor roads | 58 + | `*-tunnel_minor` | line | transportation | Minor road tunnels | 59 + | `*-tunnel_major` | line | transportation | Major road tunnels | 60 + | `*-aeroway-area` | fill | aeroway | Runway/taxiway areas | 61 + | `*-aeroway-taxiway` | line | aeroway | Taxiways | 62 + | `*-aeroway-runway` | line | aeroway | Runways | 63 + | `*-road_trunk_primary` | line | transportation | Trunk/primary roads | 64 + | `*-road_secondary_tertiary` | line | transportation | Secondary/tertiary roads | 65 + | `*-road_major_motorway` | line | transportation | Motorways | 66 + | `*-railway-transit` | line | transportation | Transit lines | 67 + | `*-railway` | line | transportation | Railways | 68 + | `*-waterway-bridge-case` | line | waterway | River bridge outlines | 69 + | `*-waterway-bridge` | line | waterway | River bridges | 70 + | `*-bridge_minor case` | line | transportation | Minor road bridge outlines | 71 + | `*-bridge_major case` | line | transportation | Major road bridge outlines | 72 + | `*-bridge_minor` | line | transportation | Minor road bridges | 73 + | `*-bridge_major` | line | transportation | Major road bridges | 74 + | `*-admin_sub` | line | boundary | Sub-national boundaries | 75 + | `*-admin_country_z0-4` | line | boundary | Country boundaries (z0-4) | 76 + | `*-admin_country_z5-` | line | boundary | Country boundaries (z5+) | 77 + | `*-poi_label` | symbol | poi | POI labels | 78 + | `*-airport-label` | symbol | aerodrome_label | Airport labels | 79 + | `*-road_major_label` | symbol | transportation_name | Road names | 80 + | `*-place_label_town` | symbol | place | Town labels | 81 + | `*-place_label_suburb` | symbol | place | Suburb labels | 82 + | `*-place_label_city` | symbol | place | City labels | 83 + | `*-country_label` | symbol | place | Country labels | 84 + 85 + ### 3. Bookmark Layers 86 + 87 + User-created bookmarks rendered on top of all map layers. 88 + 89 + | Layer ID | Type | Description | 90 + | ------------------------------- | ------ | --------------------------------------- | 91 + | `bookmarks-clusters` | circle | Clustered bookmarks (5+ points) | 92 + | `bookmarks-cluster-count` | circle | Invisible hit area for clusters | 93 + | `bookmarks-cluster-count-label` | symbol | Cluster count labels | 94 + | `bookmarks` | circle | Individual bookmark pins | 95 + | `bookmarks-hit` | circle | Invisible larger hit area for bookmarks | 96 + 97 + The bookmark layers are explicitly moved to the top of the layer stack in `#ensureBookmarkLayersOnTop()` to ensure they render above map tiles and labels. 98 + 99 + ## Layer Loading Order 100 + 101 + 1. **World tiles** load first via `#loadWorldTiles()` - provides low-res global coverage 102 + 2. **Detail tiles** load on demand via `#loadCachedDetailTiles()` - higher resolution for downloaded regions 103 + 3. **Bookmark layers** are added last and moved to top 104 + 105 + ## Z-Index in DOM 106 + 107 + The CSS establishes the following z-index stacking: 108 + 109 + - `m-map` container: `z-index: 1` 110 + - Map canvas: `z-index: 1` 111 + - MapLibre controls: `z-index: 10` 112 + - Download button: `z-index: 10` 113 + 114 + This ensures controls and buttons appear above the map canvas while maintaining the correct DOM order.
+49 -9
www/components/m-map.ts
··· 272 272 } 273 273 } 274 274 275 - #buildBookmarkPopup(name: string, id?: string): maplibregl.Popup { 275 + async #buildBookmarkPopup( 276 + name: string, 277 + id?: string, 278 + ): Promise<maplibregl.Popup> { 276 279 const container = document.createElement('div') 277 - container.style.cssText = 'display:flex;flex-direction:column;gap:4px' 280 + container.style.cssText = 'display:flex;flex-direction:column;gap:6px' 278 281 const nameEl = document.createElement('strong') 279 282 nameEl.textContent = name 280 283 container.append(nameEl) 284 + 285 + const imageUrls: string[] = [] 286 + 281 287 if (id) { 288 + const bookmark = app.bookmarks.find((b) => b.id === id) 289 + if (bookmark?.images?.length) { 290 + const thumbsRow = document.createElement('div') 291 + thumbsRow.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap' 292 + await Promise.all( 293 + bookmark.images.map(async (ref) => { 294 + const blob = await app.blobs.get(ref.hash) 295 + if (!blob) return 296 + const url = URL.createObjectURL(blob) 297 + imageUrls.push(url) 298 + const img = document.createElement('img') 299 + img.src = url 300 + img.style.cssText = 301 + 'width:64px;height:64px;object-fit:cover;border-radius:3px;cursor:pointer;display:block' 302 + img.addEventListener('click', () => { 303 + this.dispatchEvent( 304 + new CustomEvent('bookmark-image-open', { 305 + bubbles: true, 306 + composed: true, 307 + detail: { url }, 308 + }), 309 + ) 310 + }) 311 + thumbsRow.append(img) 312 + }), 313 + ) 314 + if (thumbsRow.childElementCount > 0) container.append(thumbsRow) 315 + } 316 + 282 317 const editLink = document.createElement('a') 283 318 editLink.href = `#!/bookmarks?edit=${encodeURIComponent(id)}` 284 319 editLink.textContent = 'Edit bookmark' 285 320 editLink.style.cssText = 'font-size:0.8em' 286 321 container.append(editLink) 287 322 } 288 - return new maplibregl.Popup({ offset: 10 }).setDOMContent(container) 323 + 324 + const popup = new maplibregl.Popup({ offset: 10 }).setDOMContent(container) 325 + if (imageUrls.length) { 326 + popup.on('close', () => imageUrls.forEach(URL.revokeObjectURL)) 327 + } 328 + return popup 289 329 } 290 330 291 - #showBookmarkPopup(bookmarkId: string) { 331 + async #showBookmarkPopup(bookmarkId: string) { 292 332 if (!this.#map) return 293 333 const bookmark = app.bookmarks.find((b) => b.id === bookmarkId) 294 334 if (!bookmark) return 295 335 const [lng, lat] = bookmark.geometry.coordinates 296 336 this.#bookmarkPopup?.remove() 297 - this.#bookmarkPopup = this.#buildBookmarkPopup( 337 + this.#bookmarkPopup = (await this.#buildBookmarkPopup( 298 338 bookmarkDisplayName(bookmark), 299 339 bookmarkId, 300 - ) 340 + )) 301 341 .setLngLat([lng, lat]) 302 342 .addTo(this.#map) 303 343 } ··· 433 473 zoom: baseZoom + extraZoom, 434 474 }) 435 475 }) 436 - this.#map.on('click', 'bookmarks-hit', (e) => { 476 + this.#map.on('click', 'bookmarks-hit', async (e) => { 437 477 if (!e.features?.length || !this.#map) return 438 478 const feature = e.features[0] 439 479 const coords = (feature.geometry as unknown as { ··· 441 481 }).coordinates 442 482 const id = feature.properties?._id as string | undefined 443 483 this.#bookmarkPopup?.remove() 444 - this.#bookmarkPopup = this.#buildBookmarkPopup( 484 + this.#bookmarkPopup = (await this.#buildBookmarkPopup( 445 485 feature.properties?._displayName as string, 446 486 id, 447 - ) 487 + )) 448 488 .setLngLat(coords) 449 489 .addTo(this.#map) 450 490 })
+24 -11
www/models/app.ts
··· 1 + import { BlobStore } from '@civility/blobs' 2 + import { IDBBlobStorage } from '@civility/blobs/idb' 1 3 import { Store } from '@civility/store' 2 4 import { IDBStorage } from '@civility/store/idb' 5 + import { Synced } from '@civility/sync' 3 6 import { 7 + type BlobRef, 4 8 Bookmark, 5 9 BookmarkCollection, 6 10 type BookmarkProperties, ··· 21 25 } 22 26 23 27 const backend = new IDBStorage({ dbName: 'maps-store' }) 28 + const blobStore = new BlobStore(new IDBBlobStorage({ dbName: 'maps-blobs' })) 24 29 const store = new Store(backend, { 25 30 documents: ['preferences', 'searchHistory'], 26 31 collections: ['bookmarks', 'bookmarkCollections'], ··· 39 44 }) 40 45 41 46 const bookmarksColl = store.collection<Bookmark>('bookmarks') 42 - const collectionsColl = store.collection<BookmarkCollection>( 43 - 'bookmarkCollections', 44 - ) 45 - const preferencesDoc = store.document<Preferences>( 46 - 'preferences', 47 - defaultPreferences, 48 - ) 49 - const searchHistoryDoc = store.document<SearchHistoryEntry[]>( 50 - 'searchHistory', 51 - [], 52 - ) 47 + const collectionsColl = store 48 + .collection<BookmarkCollection>('bookmarkCollections') 49 + const preferencesDoc = store 50 + .document<Preferences>('preferences', defaultPreferences) 51 + const searchHistoryDoc = store 52 + .document<SearchHistoryEntry[]>('searchHistory', []) 53 53 54 54 export class App { 55 55 #subscribers = new Set<() => void>() 56 + 57 + blobs = blobStore 58 + 59 + synced = new Synced({ 60 + stores: [bookmarksColl, collectionsColl], 61 + appId: 'map-app', 62 + blobStore, 63 + }) 56 64 57 65 constructor() { 58 66 store.subscribe(() => this.#notify()) ··· 151 159 properties?: Partial<BookmarkProperties> 152 160 categories?: string[] 153 161 zoom?: number 162 + images?: BlobRef[] 163 + visited?: boolean 154 164 }, 155 165 ): Promise<void> { 156 166 const existing = await bookmarksColl.get(id) ··· 160 170 ...(updates.categories !== undefined && 161 171 { categories: updates.categories }), 162 172 ...(updates.zoom !== undefined && { zoom: updates.zoom }), 173 + ...(updates.images !== undefined && { images: updates.images }), 174 + ...(updates.visited !== undefined && { visited: updates.visited }), 163 175 properties: updates.properties 164 176 ? { ...existing.properties, ...updates.properties } 165 177 : existing.properties, ··· 323 335 } 324 336 325 337 dispose(): void { 338 + this.synced.dispose() 326 339 store.dispose() 327 340 } 328 341 }
+1
www/models/schema.ts
··· 1 1 export { 2 + BlobRef, 2 3 Bookmark, 3 4 BookmarkAddress, 4 5 BookmarkCollection,
+9
www/models/schema/v0.ts
··· 51 51 }) 52 52 export type BookmarkProperties = z.infer<typeof BookmarkProperties> 53 53 54 + export const BlobRef = z.object({ 55 + hash: z.string(), 56 + mime: z.string(), 57 + size: z.number(), 58 + }) 59 + export type BlobRef = z.infer<typeof BlobRef> 60 + 54 61 export const Bookmark = z.object({ 55 62 id: z.string(), 56 63 type: z.literal('Feature'), ··· 62 69 properties: BookmarkProperties, 63 70 categories: z._default(z.array(z.string()), []), 64 71 zoom: z._default(z.number(), 12), 72 + images: z._default(z.array(BlobRef), []), 73 + visited: z._default(z.boolean(), false), 65 74 createdAt: z.string(), 66 75 updatedAt: z.string(), 67 76 })
+95 -9
www/routes/bookmarks.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import app from '../models/app.ts' 3 3 import { 4 + type BlobRef, 4 5 type Bookmark, 5 6 type BookmarkCollection, 6 7 bookmarkDisplayName, ··· 22 23 private pendingImport: ImportResult | null = null 23 24 private importError: string | null = null 24 25 private importTargetCollectionId: string | null = null 26 + #editingImages: Array<{ ref: BlobRef; url: string }> = [] 25 27 26 28 protected override createRenderRoot() { 27 29 return this ··· 39 41 if (!match) return 40 42 const id = decodeURIComponent(match[1]) 41 43 const bookmark = this.bookmarks.find((b) => b.id === id) 42 - if (bookmark) { 43 - this.editingBookmark = bookmark 44 - this.requestUpdate() 45 - } 44 + if (bookmark) this.#startEditingBookmark(bookmark) 46 45 history.replaceState( 47 46 null, 48 47 '', ··· 50 49 ) 51 50 } 52 51 52 + async #startEditingBookmark(b: Bookmark): Promise<void> { 53 + this.#cleanupEditingImages() 54 + this.#editingImages = await Promise.all( 55 + (b.images ?? []).map(async (ref: BlobRef) => { 56 + const blob = await app.blobs.get(ref.hash) 57 + return { ref, url: blob ? URL.createObjectURL(blob) : '' } 58 + }), 59 + ).then((entries) => entries.filter((e) => e.url)) 60 + this.editingBookmark = b 61 + this.requestUpdate() 62 + } 63 + 64 + #cleanupEditingImages(): void { 65 + for (const { url } of this.#editingImages) URL.revokeObjectURL(url) 66 + this.#editingImages = [] 67 + } 68 + 69 + async #handleImageAdd(e: Event): Promise<void> { 70 + const input = e.target as HTMLInputElement 71 + const file = input.files?.[0] 72 + if (!file) return 73 + input.value = '' 74 + const hash = await app.blobs.put(file, file.type) 75 + const ref: BlobRef = { hash, mime: file.type, size: file.size } 76 + const url = URL.createObjectURL(file) 77 + this.#editingImages = [...this.#editingImages, { ref, url }] 78 + this.requestUpdate() 79 + } 80 + 81 + #removeEditingImage(index: number): void { 82 + const removed = this.#editingImages[index] 83 + URL.revokeObjectURL(removed.url) 84 + this.#editingImages = this.#editingImages.filter((_, i) => i !== index) 85 + this.requestUpdate() 86 + } 87 + 53 88 override disconnectedCallback() { 54 89 super.disconnectedCallback() 55 90 app.removeEventListener(this.#onUpdate) ··· 95 130 const description = (fd.get('description') as string).trim() || undefined 96 131 const collectionId = (fd.get('collectionId') as string) || null 97 132 const categories = collectionId ? [collectionId] : [] 133 + const visited = fd.get('visited') === 'on' 98 134 await app.updateBookmark(this.editingBookmark.id, { 99 135 properties: { 100 136 ...this.editingBookmark.properties, ··· 108 144 : this.editingBookmark.properties.address, 109 145 }, 110 146 categories, 147 + visited, 148 + images: this.#editingImages.map((e) => e.ref), 111 149 }) 150 + this.#cleanupEditingImages() 112 151 this.editingBookmark = null 113 152 this.requestUpdate() 114 153 } ··· 553 592 <ui-dialog 554 593 ?open="${this.editingBookmark !== null}" 555 594 @dismiss="${() => { 595 + this.#cleanupEditingImages() 556 596 this.editingBookmark = null 557 597 this.requestUpdate() 558 598 }}" ··· 607 647 ` 608 648 : '' 609 649 })()} 650 + <label class="settings-toggle-label"> 651 + <span>Visited</span> 652 + <input 653 + type="checkbox" 654 + name="visited" 655 + .checked="${this.editingBookmark.visited}" 656 + > 657 + </label> 658 + 659 + <div class="bm-images"> 660 + ${this.#editingImages.map(({ url }, i) => 661 + html` 662 + <div class="bm-image-thumb"> 663 + <img src="${url}" alt=""> 664 + <button 665 + type="button" 666 + class="bm-image-remove" 667 + aria-label="Remove image" 668 + @click="${() => this.#removeEditingImage(i)}" 669 + > 670 + × 671 + </button> 672 + </div> 673 + ` 674 + )} 675 + <label class="bm-image-add" aria-label="Add image"> 676 + + 677 + <input 678 + type="file" 679 + accept="image/*" 680 + style="display:none" 681 + @change="${this.#handleImageAdd}" 682 + > 683 + </label> 684 + </div> 685 + 610 686 <p class="bm-dialog-coords"> 611 687 ${this.editingBookmark.geometry.coordinates[1].toFixed( 612 688 5, ··· 644 720 <button 645 721 type="button" 646 722 @click="${() => { 723 + this.#cleanupEditingImages() 647 724 this.editingBookmark = null 648 725 this.requestUpdate() 649 726 }}" ··· 744 821 return html` 745 822 <div class="bm-item"> 746 823 <div class="bm-item-info"> 747 - <span class="bm-item-name">${bookmarkDisplayName(b)}</span> 824 + <span class="bm-item-name"> 825 + ${bookmarkDisplayName(b)} ${b.visited 826 + ? html` 827 + <span class="bm-visited-badge" title="Visited">✓</span> 828 + ` 829 + : ''} ${b.images?.length > 0 830 + ? html` 831 + <span class="bm-image-badge" title="${b.images 832 + .length} image${b.images.length === 1 ? '' : 's'}" 833 + >📷 ${b.images.length}</span> 834 + ` 835 + : ''} 836 + </span> 748 837 <span class="bm-item-coords">${lat.toFixed(5)}, ${lng.toFixed( 749 838 5, 750 839 )}</span> ··· 760 849 <button 761 850 class="bm-icon-btn" 762 851 aria-label="Edit bookmark" 763 - @click="${() => { 764 - this.editingBookmark = b 765 - this.requestUpdate() 766 - }}" 852 + @click="${() => this.#startEditingBookmark(b)}" 767 853 > 768 854 <img src="/static/icons/edit.svg" alt="" aria-hidden="true"> 769 855 </button>
+30 -1
www/routes/map.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 3 3 export class MapPage extends LitElement { 4 + #lightboxUrl: string | null = null 5 + 4 6 protected override createRenderRoot() { 5 7 return this 6 8 } 7 9 10 + override connectedCallback() { 11 + super.connectedCallback() 12 + document.addEventListener('bookmark-image-open', this.#onImageOpen) 13 + } 14 + 15 + override disconnectedCallback() { 16 + super.disconnectedCallback() 17 + document.removeEventListener('bookmark-image-open', this.#onImageOpen) 18 + } 19 + 20 + #onImageOpen = (e: Event) => { 21 + this.#lightboxUrl = (e as CustomEvent<{ url: string }>).detail.url 22 + this.requestUpdate() 23 + } 24 + 8 25 override render(): TemplateResult { 9 26 return html` 10 - 27 + <ui-dialog 28 + ?open="${this.#lightboxUrl !== null}" 29 + @dismiss="${() => { 30 + this.#lightboxUrl = null 31 + this.requestUpdate() 32 + }}" 33 + > 34 + <dialog> 35 + <article class="bm-lightbox"> 36 + <img src="${this.#lightboxUrl ?? ''}" alt=""> 37 + </article> 38 + </dialog> 39 + </ui-dialog> 11 40 ` 12 41 } 13 42 }
+5
www/routes/settings.ts
··· 80 80 </section> 81 81 82 82 <section> 83 + <h2>Sync</h2> 84 + <ui-sync storage-key="maps-sync" .synced="${app.synced}"></ui-sync> 85 + </section> 86 + 87 + <section> 83 88 <h2>Data</h2> 84 89 <p>Export your data to a file, or import a previously exported file.</p> 85 90 <div class="settings-data-actions">
+67
www/static/styles/theme.css
··· 666 666 font-variant-numeric: tabular-nums; 667 667 } 668 668 669 + .bm-images { 670 + display: flex; 671 + flex-wrap: wrap; 672 + gap: var(--s2); 673 + } 674 + 675 + .bm-image-thumb { 676 + position: relative; 677 + width: 80px; 678 + height: 80px; 679 + flex-shrink: 0; 680 + } 681 + 682 + .bm-image-thumb img { 683 + width: 100%; 684 + height: 100%; 685 + object-fit: cover; 686 + border-radius: 4px; 687 + display: block; 688 + } 689 + 690 + .bm-image-remove { 691 + position: absolute; 692 + top: 2px; 693 + right: 2px; 694 + width: 20px; 695 + height: 20px; 696 + padding: 0; 697 + line-height: 1; 698 + font-size: 14px; 699 + border-radius: 50%; 700 + cursor: pointer; 701 + } 702 + 703 + .bm-image-add { 704 + display: flex; 705 + align-items: center; 706 + justify-content: center; 707 + width: 80px; 708 + height: 80px; 709 + border: 2px dashed currentColor; 710 + border-radius: 4px; 711 + font-size: 24px; 712 + opacity: 0.5; 713 + cursor: pointer; 714 + flex-shrink: 0; 715 + } 716 + 717 + .bm-image-add:hover { 718 + opacity: 1; 719 + } 720 + 721 + .bm-lightbox { 722 + padding: var(--s2); 723 + display: flex; 724 + align-items: center; 725 + justify-content: center; 726 + } 727 + 728 + .bm-lightbox img { 729 + max-width: min(90vw, 960px); 730 + max-height: 85vh; 731 + object-fit: contain; 732 + display: block; 733 + border-radius: 4px; 734 + } 735 + 669 736 /* ── Import confirmation dialog ─────────────────────────────────────────── */ 670 737 671 738 .bm-import-error {