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

Configure Feed

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

chore: breakout components

+508 -328
+132
www/components/m-bookmark-add-popup.ts
··· 1 + import { html, LitElement, nothing, type TemplateResult } from 'lit' 2 + import maplibregl from 'maplibre-gl' 3 + import app from '../models/app.ts' 4 + import { from as nominatimToProperties } from '../models/adapters/nominatim.ts' 5 + import { nominatimReverse } from '../utils/nominatim.ts' 6 + 7 + type ResolvedProperties = ReturnType<typeof nominatimToProperties> 8 + 9 + export class MBookmarkAddPopup extends LitElement { 10 + lat = 0 11 + lng = 0 12 + zoom = 12 13 + initialName = '' 14 + address?: string 15 + geocode = false 16 + 17 + #resolved: ResolvedProperties | undefined 18 + #geocoding = false 19 + #saved = false 20 + 21 + protected override createRenderRoot() { 22 + return this 23 + } 24 + 25 + override connectedCallback() { 26 + super.connectedCallback() 27 + if (this.geocode && !this.address && app.geocodingBookmarksEnabled) { 28 + this.#geocoding = true 29 + this.#reverseGeocode() 30 + } 31 + } 32 + 33 + async #reverseGeocode() { 34 + try { 35 + const result = await nominatimReverse(this.lat, this.lng) 36 + if (result?.display_name) { 37 + this.#resolved = nominatimToProperties(result) 38 + this.address = result.display_name 39 + const input = this.querySelector<HTMLInputElement>('input') 40 + const coordsPlaceholder = this.#coordsString() 41 + if (input && input.value === coordsPlaceholder) { 42 + input.value = result.display_name.split(',')[0].trim() 43 + } 44 + } 45 + } catch { 46 + // swallow; address just won't be populated 47 + } finally { 48 + this.#geocoding = false 49 + this.requestUpdate() 50 + } 51 + } 52 + 53 + #coordsString(): string { 54 + return `${this.lat.toFixed(5)}, ${this.lng.toFixed(5)}` 55 + } 56 + 57 + async #onSave() { 58 + const input = this.querySelector<HTMLInputElement>('input') 59 + const fallback = this.initialName || this.#coordsString() 60 + const name = input?.value.trim() || fallback 61 + this.#saved = true 62 + this.requestUpdate() 63 + await app.addBookmark(this.lat, this.lng, this.zoom, { 64 + ...(this.#resolved ?? {}), 65 + name, 66 + address: this.address ? { displayText: this.address } : undefined, 67 + }) 68 + } 69 + 70 + override firstUpdated() { 71 + const input = this.querySelector<HTMLInputElement>('input') 72 + if (input) input.value = this.initialName || this.#coordsString() 73 + } 74 + 75 + override render(): TemplateResult { 76 + const addressText = this.address ?? 77 + (this.#geocoding ? 'Looking up location…' : null) 78 + return html` 79 + <div 80 + style="display:flex;flex-direction:column;gap:6px;min-width:180px" 81 + > 82 + ${addressText 83 + ? html` 84 + <p 85 + style="margin:0;font-size:0.75em;opacity:0.6;word-break:break-word;max-width:220px" 86 + > 87 + ${addressText} 88 + </p> 89 + ` 90 + : nothing} 91 + <input 92 + type="text" 93 + placeholder="Bookmark name" 94 + style="display:block;width:100%;box-sizing:border-box" 95 + /> 96 + <button 97 + style="width:100%" 98 + ?disabled="${this.#saved}" 99 + @click="${this.#onSave}" 100 + > 101 + ${this.#saved ? 'Saved!' : 'Add to bookmarks'} 102 + </button> 103 + </div> 104 + ` 105 + } 106 + } 107 + 108 + customElements.define('m-bookmark-add-popup', MBookmarkAddPopup) 109 + 110 + export type BookmarkAddPopupOptions = { 111 + lat: number 112 + lng: number 113 + zoom: number 114 + initialName?: string 115 + address?: string 116 + geocode?: boolean 117 + } 118 + 119 + export function createBookmarkAddPopup( 120 + opts: BookmarkAddPopupOptions, 121 + ): maplibregl.Popup { 122 + const el = document.createElement( 123 + 'm-bookmark-add-popup', 124 + ) as MBookmarkAddPopup 125 + el.lat = opts.lat 126 + el.lng = opts.lng 127 + el.zoom = opts.zoom 128 + el.initialName = opts.initialName ?? '' 129 + el.address = opts.address 130 + el.geocode = opts.geocode ?? false 131 + return new maplibregl.Popup({ offset: 25 }).setDOMContent(el) 132 + }
+97
www/components/m-bookmark-view-popup.ts
··· 1 + import { html, LitElement, nothing, type TemplateResult } from 'lit' 2 + import maplibregl from 'maplibre-gl' 3 + import app from '../models/app.ts' 4 + 5 + export class MBookmarkViewPopup extends LitElement { 6 + bookmarkId = '' 7 + displayName = '' 8 + 9 + #thumbs: string[] = [] 10 + 11 + protected override createRenderRoot() { 12 + return this 13 + } 14 + 15 + override connectedCallback() { 16 + super.connectedCallback() 17 + this.#loadThumbs() 18 + } 19 + 20 + override disconnectedCallback() { 21 + super.disconnectedCallback() 22 + this.#thumbs.forEach(URL.revokeObjectURL) 23 + this.#thumbs = [] 24 + } 25 + 26 + async #loadThumbs() { 27 + if (!this.bookmarkId) return 28 + const bookmark = app.bookmarks.find((b) => b.id === this.bookmarkId) 29 + if (!bookmark?.images?.length) return 30 + const urls = await Promise.all( 31 + bookmark.images.map(async (ref) => { 32 + const blob = await app.blobs.get(ref.hash) 33 + return blob ? URL.createObjectURL(blob) : null 34 + }), 35 + ) 36 + this.#thumbs = urls.filter((u): u is string => u !== null) 37 + this.requestUpdate() 38 + } 39 + 40 + #onThumbClick(url: string) { 41 + this.dispatchEvent( 42 + new CustomEvent('bookmark-image-open', { 43 + bubbles: true, 44 + composed: true, 45 + detail: { url }, 46 + }), 47 + ) 48 + } 49 + 50 + override render(): TemplateResult { 51 + return html` 52 + <div style="display:flex;flex-direction:column;gap:6px"> 53 + <strong>${this.displayName}</strong> 54 + ${this.#thumbs.length 55 + ? html` 56 + <div style="display:flex;gap:4px;flex-wrap:wrap"> 57 + ${this.#thumbs.map((url) => 58 + html` 59 + <img 60 + src="${url}" 61 + style="width:64px;height:64px;object-fit:cover;border-radius:3px;cursor:pointer;display:block" 62 + @click="${() => this.#onThumbClick(url)}" 63 + /> 64 + ` 65 + )} 66 + </div> 67 + ` 68 + : nothing} ${this.bookmarkId 69 + ? html` 70 + <a 71 + href="#!/bookmarks?edit=${encodeURIComponent(this.bookmarkId)}" 72 + style="font-size:0.8em" 73 + >Edit bookmark</a> 74 + ` 75 + : nothing} 76 + </div> 77 + ` 78 + } 79 + } 80 + 81 + customElements.define('m-bookmark-view-popup', MBookmarkViewPopup) 82 + 83 + export type BookmarkViewPopupOptions = { 84 + bookmarkId?: string 85 + displayName: string 86 + } 87 + 88 + export function createBookmarkViewPopup( 89 + opts: BookmarkViewPopupOptions, 90 + ): maplibregl.Popup { 91 + const el = document.createElement( 92 + 'm-bookmark-view-popup', 93 + ) as MBookmarkViewPopup 94 + el.bookmarkId = opts.bookmarkId ?? '' 95 + el.displayName = opts.displayName 96 + return new maplibregl.Popup({ offset: 10 }).setDOMContent(el) 97 + }
+19 -171
www/components/m-map.ts
··· 12 12 import { WORLD_LAYERS } from '../utils/layers.ts' 13 13 import app from '../models/app.ts' 14 14 import { bookmarkDisplayName } from '../models/schema.ts' 15 - import { from as nominatimToProperties } from '../models/adapters/nominatim.ts' 16 - import { nominatimReverse } from '../utils/nominatim.ts' 17 15 import { boundsIntersect, expandBounds } from '../utils/bounds.ts' 18 16 import { 19 17 fetchTileManifest, ··· 24 22 ensureBookmarkLayersOnTop, 25 23 setupBookmarkLayers, 26 24 } from '../utils/bookmark-layers.ts' 25 + import { createBookmarkAddPopup } from './m-bookmark-add-popup.ts' 26 + import { createBookmarkViewPopup } from './m-bookmark-view-popup.ts' 27 27 28 28 const protocol = new Protocol() 29 29 maplibregl.addProtocol('pmtiles', protocol.tile.bind(protocol)) ··· 157 157 this.#marker = new maplibregl.Marker({ color: '#16a34a' }) 158 158 .setLngLat([target.lng, target.lat]) 159 159 .setPopup( 160 - this.#buildPopup( 161 - target.name ?? 'Unknown location', 162 - target.lat, 163 - target.lng, 164 - target.zoom, 165 - target.address, 166 - ), 160 + createBookmarkAddPopup({ 161 + lat: target.lat, 162 + lng: target.lng, 163 + zoom: target.zoom, 164 + initialName: target.name ?? 'Unknown location', 165 + address: target.address, 166 + }), 167 167 ) 168 168 .addTo(this.#map!) 169 169 this.#marker.togglePopup() ··· 181 181 app.setLastView({ lat, lng, zoom }) 182 182 } 183 183 184 - #buildPopup( 185 - name: string, 186 - lat: number, 187 - lng: number, 188 - zoom: number, 189 - address?: string, 190 - ): maplibregl.Popup { 191 - const container = document.createElement('div') 192 - container.style.cssText = 193 - 'display:flex;flex-direction:column;gap:6px;min-width:180px' 194 - 195 - if (address) { 196 - const addr = document.createElement('p') 197 - addr.style.cssText = 198 - 'margin:0;font-size:0.75em;opacity:0.6;word-break:break-word;max-width:220px' 199 - addr.textContent = address 200 - container.append(addr) 201 - } 202 - 203 - const input = document.createElement('input') 204 - input.type = 'text' 205 - input.value = name 206 - input.placeholder = 'Bookmark name' 207 - input.style.cssText = 'display:block;width:100%;box-sizing:border-box' 208 - 209 - const button = document.createElement('button') 210 - button.textContent = 'Add to bookmarks' 211 - button.style.cssText = 'width:100%' 212 - 213 - button.addEventListener('click', async () => { 214 - button.disabled = true 215 - await app.addBookmark(lat, lng, zoom, { 216 - name: input.value.trim() || name, 217 - address: address ? { displayText: address } : undefined, 218 - }) 219 - button.textContent = 'Saved!' 220 - }) 221 - 222 - container.append(input, button) 223 - return new maplibregl.Popup({ offset: 25 }).setDOMContent(container) 224 - } 225 - 226 184 #clearLongPress() { 227 185 if (this.#longPressTimer) { 228 186 clearTimeout(this.#longPressTimer) ··· 234 192 if (!this.#map) return 235 193 const { lat, lng } = lngLat 236 194 const zoom = this.#map.getZoom() 237 - 238 - const container = document.createElement('div') 239 - container.style.cssText = 240 - 'display:flex;flex-direction:column;gap:6px;min-width:180px' 241 - 242 - const addrEl = document.createElement('p') 243 - addrEl.style.cssText = 244 - 'margin:0;font-size:0.75em;opacity:0.6;word-break:break-word;max-width:220px' 245 - addrEl.textContent = 'Looking up location…' 246 - container.append(addrEl) 247 - 248 - const input = document.createElement('input') 249 - input.type = 'text' 250 - input.value = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 251 - input.placeholder = 'Bookmark name' 252 - input.style.cssText = 'display:block;width:100%;box-sizing:border-box' 253 - 254 - const button = document.createElement('button') 255 - button.textContent = 'Add to bookmarks' 256 - button.style.cssText = 'width:100%' 257 - 258 - let resolvedProperties: ReturnType<typeof nominatimToProperties> | undefined 259 - 260 - button.addEventListener('click', async () => { 261 - button.disabled = true 262 - const coordsFallback = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 263 - const name = input.value.trim() || coordsFallback 264 - await app.addBookmark(lat, lng, zoom, { 265 - ...(resolvedProperties ?? {}), 266 - name, 267 - }) 268 - button.textContent = 'Saved!' 269 - }) 270 - 271 - container.append(input, button) 272 - 195 + const popup = createBookmarkAddPopup({ lat, lng, zoom, geocode: true }) 273 196 this.#marker?.remove() 274 197 this.#marker = new maplibregl.Marker() 275 198 .setLngLat([lng, lat]) 276 - .setPopup(new maplibregl.Popup({ offset: 25 }).setDOMContent(container)) 199 + .setPopup(popup) 277 200 .addTo(this.#map) 278 201 this.#marker.togglePopup() 279 - 280 - if (!app.geocodingBookmarksEnabled) { 281 - addrEl.remove() 282 - } else { 283 - nominatimReverse(lat, lng) 284 - .then((result) => { 285 - if (!result?.display_name) { 286 - addrEl.remove() 287 - return 288 - } 289 - resolvedProperties = nominatimToProperties(result) 290 - addrEl.textContent = result.display_name 291 - const coordsPlaceholder = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 292 - if (input.value === coordsPlaceholder) { 293 - input.value = result.display_name.split(',')[0].trim() 294 - } 295 - }) 296 - .catch(() => addrEl.remove()) 297 - } 298 202 } 299 203 300 - async #buildBookmarkPopup( 301 - name: string, 302 - id?: string, 303 - ): Promise<maplibregl.Popup> { 304 - const container = document.createElement('div') 305 - container.style.cssText = 'display:flex;flex-direction:column;gap:6px' 306 - const nameEl = document.createElement('strong') 307 - nameEl.textContent = name 308 - container.append(nameEl) 309 - 310 - const imageUrls: string[] = [] 311 - 312 - if (id) { 313 - const bookmark = app.bookmarks.find((b) => b.id === id) 314 - if (bookmark?.images?.length) { 315 - const thumbsRow = document.createElement('div') 316 - thumbsRow.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap' 317 - await Promise.all( 318 - bookmark.images.map(async (ref) => { 319 - const blob = await app.blobs.get(ref.hash) 320 - if (!blob) return 321 - const url = URL.createObjectURL(blob) 322 - imageUrls.push(url) 323 - const img = document.createElement('img') 324 - img.src = url 325 - img.style.cssText = 326 - 'width:64px;height:64px;object-fit:cover;border-radius:3px;cursor:pointer;display:block' 327 - img.addEventListener('click', () => { 328 - this.dispatchEvent( 329 - new CustomEvent('bookmark-image-open', { 330 - bubbles: true, 331 - composed: true, 332 - detail: { url }, 333 - }), 334 - ) 335 - }) 336 - thumbsRow.append(img) 337 - }), 338 - ) 339 - if (thumbsRow.childElementCount > 0) container.append(thumbsRow) 340 - } 341 - 342 - const editLink = document.createElement('a') 343 - editLink.href = `#!/bookmarks?edit=${encodeURIComponent(id)}` 344 - editLink.textContent = 'Edit bookmark' 345 - editLink.style.cssText = 'font-size:0.8em' 346 - container.append(editLink) 347 - } 348 - 349 - const popup = new maplibregl.Popup({ offset: 10 }).setDOMContent(container) 350 - if (imageUrls.length) { 351 - popup.on('close', () => imageUrls.forEach(URL.revokeObjectURL)) 352 - } 353 - return popup 354 - } 355 - 356 - async #showBookmarkPopup(bookmarkId: string) { 204 + #showBookmarkPopup(bookmarkId: string) { 357 205 if (!this.#map) return 358 206 const bookmark = app.bookmarks.find((b) => b.id === bookmarkId) 359 207 if (!bookmark) return 360 208 const [lng, lat] = bookmark.geometry.coordinates 361 209 this.#bookmarkPopup?.remove() 362 - this.#bookmarkPopup = (await this.#buildBookmarkPopup( 363 - bookmarkDisplayName(bookmark), 210 + this.#bookmarkPopup = createBookmarkViewPopup({ 364 211 bookmarkId, 365 - )) 212 + displayName: bookmarkDisplayName(bookmark), 213 + }) 366 214 .setLngLat([lng, lat]) 367 215 .addTo(this.#map) 368 216 } ··· 390 238 const extraZoom = pointCount > 50 ? 2 : pointCount > 20 ? 1 : 0 391 239 this.#map.easeTo({ center: coords, zoom: baseZoom + extraZoom }) 392 240 }, 393 - onBookmarkClick: async (coords, id, displayName) => { 241 + onBookmarkClick: (coords, id, displayName) => { 394 242 if (!this.#map) return 395 243 this.#bookmarkPopup?.remove() 396 - this.#bookmarkPopup = (await this.#buildBookmarkPopup( 244 + this.#bookmarkPopup = createBookmarkViewPopup({ 245 + bookmarkId: id, 397 246 displayName, 398 - id, 399 - )) 247 + }) 400 248 .setLngLat(coords) 401 249 .addTo(this.#map) 402 250 },
+217
www/components/ui-data-actions.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + 3 + export interface Methods { 4 + exportData(filename?: string): Promise<{ 5 + success: boolean 6 + path?: string 7 + error?: string 8 + }> 9 + importData(): Promise<{ 10 + success: boolean 11 + path?: string 12 + error?: string 13 + }> 14 + deleteAllData(): Promise<{ success: boolean; error?: string }> 15 + } 16 + 17 + export class UiDataActions extends LitElement { 18 + static override properties = { 19 + methods: { type: Object }, 20 + _isImporting: { state: true }, 21 + _showDeleteConfirm: { state: true }, 22 + _status: { state: true }, 23 + _deleteStatus: { state: true }, 24 + } 25 + 26 + declare methods: Methods | null 27 + declare _isImporting: boolean 28 + declare _showDeleteConfirm: boolean 29 + declare _status: string 30 + declare _deleteStatus: string 31 + 32 + constructor() { 33 + super() 34 + this.methods = null 35 + this._isImporting = false 36 + this._showDeleteConfirm = false 37 + this._status = '' 38 + this._deleteStatus = '' 39 + } 40 + 41 + override createRenderRoot() { 42 + return this 43 + } 44 + 45 + #setStatus(msg: string, isError = false): void { 46 + this._status = isError ? `Error: ${msg}` : msg 47 + } 48 + 49 + #setDeleteStatus(msg: string): void { 50 + this._deleteStatus = msg 51 + } 52 + 53 + async #handleExport(): Promise<void> { 54 + if (!this.methods) return 55 + const btn = this.querySelector<HTMLButtonElement>('[data-export-btn]') 56 + if (btn) btn.disabled = true 57 + const result = await this.methods.exportData() 58 + if (btn) btn.disabled = false 59 + if (result.success) { 60 + this.#setStatus('Data exported successfully.') 61 + this.dispatchEvent( 62 + new CustomEvent('on-exported', { bubbles: true, composed: true }), 63 + ) 64 + } else { 65 + this.#setStatus(result.error ?? 'Export failed.', true) 66 + } 67 + } 68 + 69 + #handleDeleteAllData(): void { 70 + this._showDeleteConfirm = true 71 + this._deleteStatus = '' 72 + this.requestUpdate() 73 + } 74 + 75 + #handleDeleteCancel = (): void => { 76 + this._showDeleteConfirm = false 77 + this._deleteStatus = '' 78 + this.requestUpdate() 79 + } 80 + 81 + async #handleDeleteConfirm(): Promise<void> { 82 + if (!this.methods) return 83 + const input = this.querySelector<HTMLInputElement>( 84 + '[data-delete-confirm-input]', 85 + ) 86 + if (input?.value !== 'Delete my data') { 87 + this.#setDeleteStatus('Please type "Delete my data" exactly to confirm.') 88 + return 89 + } 90 + const btn = this.querySelector<HTMLButtonElement>( 91 + '[data-delete-confirm-btn]', 92 + ) 93 + if (btn) btn.disabled = true 94 + const result = await this.methods.deleteAllData() 95 + if (result.success) { 96 + this._showDeleteConfirm = false 97 + this.#setStatus('All data deleted.') 98 + this.dispatchEvent( 99 + new CustomEvent('on-deleted', { bubbles: true, composed: true }), 100 + ) 101 + globalThis.location.reload() 102 + } else { 103 + if (btn) btn.disabled = false 104 + this.#setDeleteStatus(result.error ?? 'Delete failed.') 105 + } 106 + } 107 + 108 + async #handleImport(): Promise<void> { 109 + if (!this.methods) return 110 + this._isImporting = true 111 + this.requestUpdate() 112 + const result = await this.methods.importData() 113 + this._isImporting = false 114 + this.requestUpdate() 115 + if (result.success) { 116 + this.#setStatus('Data imported successfully.') 117 + this.dispatchEvent( 118 + new CustomEvent('on-imported', { bubbles: true, composed: true }), 119 + ) 120 + } else { 121 + this.#setStatus(result.error ?? 'Import failed.', true) 122 + } 123 + } 124 + 125 + override render(): TemplateResult { 126 + return html` 127 + <p>Export your data to a file, or import a previously exported file.</p> 128 + <div> 129 + <button 130 + class="action" 131 + data-export-btn 132 + @click="${this.#handleExport}" 133 + > 134 + Export 135 + </button> 136 + <button 137 + class="action" 138 + ?disabled="${this._isImporting}" 139 + @click="${this.#handleImport}" 140 + > 141 + ${this._isImporting ? 'Importing...' : 'Import'} 142 + </button> 143 + </div> 144 + 145 + <button class="action action--danger" @click="${this 146 + .#handleDeleteAllData}"> 147 + Delete All Data 148 + </button> 149 + 150 + ${this._status 151 + ? html` 152 + <p data-status ${this._status.startsWith('Error') 153 + ? 'data-error' 154 + : ''}> 155 + ${this._status} 156 + </p> 157 + ` 158 + : ''} 159 + 160 + <ui-dialog 161 + ?open="${this._showDeleteConfirm}" 162 + @dismiss="${this.#handleDeleteCancel}" 163 + > 164 + <dialog> 165 + <article class="bm-dialog"> 166 + <h2 class="bm-dialog-title">Delete All Data</h2> 167 + <p> 168 + This will permanently delete all your data. This cannot be undone. 169 + </p> 170 + <p>Type <strong>Delete my data</strong> to confirm:</p> 171 + <input 172 + data-delete-confirm-input 173 + type="text" 174 + placeholder="Delete my data" 175 + autocomplete="off" 176 + > 177 + ${this._deleteStatus 178 + ? html` 179 + <p data-status data-error> 180 + ${this._deleteStatus} 181 + </p> 182 + ` 183 + : ''} 184 + <div class="bm-form-actions"> 185 + <button 186 + class="bm-btn-danger" 187 + data-delete-confirm-btn 188 + @click="${this.#handleDeleteConfirm}" 189 + > 190 + Delete 191 + </button> 192 + <button type="button" @click="${this.#handleDeleteCancel}"> 193 + Cancel 194 + </button> 195 + </div> 196 + </article> 197 + </dialog> 198 + </ui-dialog> 199 + 200 + <ui-dialog ?open="${this._isImporting}"> 201 + <dialog> 202 + <article class="bm-dialog"> 203 + <h2 class="bm-dialog-title">Importing Data</h2> 204 + <p>Please wait while your data is being imported...</p> 205 + <div style="display:flex;justify-content:center;padding:16px 0"> 206 + <ui-spinner size="lg"></ui-spinner> 207 + </div> 208 + </article> 209 + </dialog> 210 + </ui-dialog> 211 + ` 212 + } 213 + } 214 + 215 + if (!customElements.get('ui-data-actions')) { 216 + customElements.define('ui-data-actions', UiDataActions) 217 + }
+1
www/index.ts
··· 2 2 import { createLayoutRouter } from '@civility/ui' 3 3 import './routes/map.ts' 4 4 import './components/m-map.ts' 5 + import './components/ui-data-actions.ts' 5 6 import './routes/search.ts' 6 7 import './routes/settings.ts' 7 8 import './routes/settings-downloads.ts'
+11 -148
www/routes/settings.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import app from '../models/app.ts' 3 + import type { Methods } from '../components/ui-data-actions.ts' 3 4 4 5 export class SettingsPage extends LitElement { 5 6 protected override createRenderRoot() { ··· 7 8 } 8 9 9 10 #onAppUpdate = () => this.requestUpdate() 10 - #showDeleteConfirm = false 11 - #isImporting = false 12 11 13 12 override connectedCallback() { 14 13 super.connectedCallback() ··· 87 86 88 87 <section> 89 88 <h2>Data</h2> 90 - <p>Export your data to a file, or import a previously exported file.</p> 91 - <div class="settings-data-actions"> 92 - <button class="action" id="settings-export" @click="${this 93 - .#handleExport}"> 94 - Export 95 - </button> 96 - <button 97 - class="action" 98 - id="settings-import" 99 - ?disabled="${this.#isImporting}" 100 - @click="${this.#handleImport}" 101 - > 102 - ${this.#isImporting ? 'Importing...' : 'Import'} 103 - </button> 104 - </div> 105 - 106 - <button class="action action--danger" @click="${this 107 - .#handleDeleteAllData}"> 108 - Delete All Data 109 - </button> 110 - <p id="settings-data-status" class="settings-data-status" hidden></p> 89 + <ui-data-actions 90 + .methods="${{ 91 + exportData: (filename?: string) => app.exportStore(filename), 92 + importData: () => app.importStore(), 93 + deleteAllData: () => app.deleteAllData(), 94 + } as Methods}" 95 + @on-imported="${() => { 96 + globalThis.dispatchEvent(new CustomEvent('pmtiles-updated')) 97 + }}" 98 + ></ui-data-actions> 111 99 </section> 112 100 113 101 <section> ··· 127 115 </a> 128 116 </nav> 129 117 </section> 130 - 131 - <ui-dialog 132 - ?open="${this.#showDeleteConfirm}" 133 - @dismiss="${this.#handleDeleteCancel}" 134 - > 135 - <dialog> 136 - <article class="bm-dialog"> 137 - <h2 class="bm-dialog-title">Delete All Data</h2> 138 - <p> 139 - This will permanently delete all your bookmarks, search history, offline 140 - maps, and preferences. This cannot be undone. 141 - </p> 142 - <p>Type <strong>Delete my data</strong> to confirm:</p> 143 - <input 144 - id="settings-delete-confirm-input" 145 - type="text" 146 - placeholder="Delete my data" 147 - autocomplete="off" 148 - > 149 - <p id="settings-delete-status" class="settings-data-status" hidden></p> 150 - <div class="bm-form-actions"> 151 - <button 152 - id="settings-delete-confirm-btn" 153 - class="bm-btn-danger" 154 - @click="${this.#handleDeleteConfirm}" 155 - > 156 - Delete 157 - </button> 158 - <button type="button" @click="${this.#handleDeleteCancel}"> 159 - Cancel 160 - </button> 161 - </div> 162 - </article> 163 - </dialog> 164 - </ui-dialog> 165 - 166 - <ui-dialog ?open="${this.#isImporting}"> 167 - <dialog> 168 - <article class="bm-dialog"> 169 - <h2 class="bm-dialog-title">Importing Data</h2> 170 - <p>Please wait while your data is being imported...</p> 171 - <div style="display:flex;justify-content:center;padding:16px 0"> 172 - <ui-spinner size="lg"></ui-spinner> 173 - </div> 174 - </article> 175 - </dialog> 176 - </ui-dialog> 177 118 ` 178 119 } 179 120 ··· 183 124 184 125 #handleGeocodingToggle(e: Event): void { 185 126 app.setGeocodingBookmarksEnabled((e.target as HTMLInputElement).checked) 186 - } 187 - 188 - #setStatus(msg: string, isError = false): void { 189 - const el = this.querySelector<HTMLElement>('#settings-data-status') 190 - if (!el) return 191 - el.textContent = msg 192 - el.hidden = false 193 - el.className = isError 194 - ? 'settings-data-status settings-data-status--error' 195 - : 'settings-data-status' 196 - } 197 - 198 - async #handleExport(): Promise<void> { 199 - const btn = this.querySelector<HTMLButtonElement>('#settings-export') 200 - if (btn) btn.disabled = true 201 - const result = await app.exportStore() 202 - if (btn) btn.disabled = false 203 - if (result.success) { 204 - this.#setStatus('Data exported successfully.') 205 - } else { 206 - this.#setStatus(result.error ?? 'Export failed.', true) 207 - } 208 - } 209 - 210 - #handleDeleteAllData(): void { 211 - this.#showDeleteConfirm = true 212 - this.requestUpdate() 213 - } 214 - 215 - #handleDeleteCancel = (): void => { 216 - this.#showDeleteConfirm = false 217 - this.requestUpdate() 218 - } 219 - 220 - #setDeleteStatus(msg: string): void { 221 - const el = this.querySelector<HTMLElement>('#settings-delete-status') 222 - if (!el) return 223 - el.textContent = msg 224 - el.hidden = false 225 - el.className = 'settings-data-status settings-data-status--error' 226 - } 227 - 228 - async #handleDeleteConfirm(): Promise<void> { 229 - const input = this.querySelector<HTMLInputElement>( 230 - '#settings-delete-confirm-input', 231 - ) 232 - if (input?.value !== 'Delete my data') { 233 - this.#setDeleteStatus('Please type "Delete my data" exactly to confirm.') 234 - return 235 - } 236 - const btn = this.querySelector<HTMLButtonElement>( 237 - '#settings-delete-confirm-btn', 238 - ) 239 - if (btn) btn.disabled = true 240 - const result = await app.deleteAllData() 241 - if (result.success) { 242 - this.#showDeleteConfirm = false 243 - this.requestUpdate() 244 - this.#setStatus('All data deleted.') 245 - globalThis.location.reload() 246 - } else { 247 - if (btn) btn.disabled = false 248 - this.#setDeleteStatus(result.error ?? 'Delete failed.') 249 - } 250 - } 251 - 252 - async #handleImport(): Promise<void> { 253 - this.#isImporting = true 254 - this.requestUpdate() 255 - const result = await app.importStore() 256 - this.#isImporting = false 257 - this.requestUpdate() 258 - if (result.success) { 259 - this.#setStatus('Data imported successfully.') 260 - globalThis.dispatchEvent(new CustomEvent('pmtiles-updated')) 261 - } else { 262 - this.#setStatus(result.error ?? 'Import failed.', true) 263 - } 264 127 } 265 128 } 266 129
+31 -9
www/static/styles/theme.css
··· 59 59 overflow: hidden; 60 60 } 61 61 62 - body:has(r-home) > main { 62 + body:has(r-home)>main { 63 63 overflow: hidden; 64 64 } 65 65 ··· 100 100 z-index: 100; 101 101 } 102 102 103 - m-map > .download-btn { 103 + m-map>.download-btn { 104 104 z-index: 10; 105 105 } 106 106 ··· 233 233 margin-bottom: var(--s3); 234 234 } 235 235 236 - r-settings section > p, 237 - r-settings-downloads section > p, 238 - r-settings-about section > p { 236 + r-settings section>p, 237 + r-settings-downloads section>p, 238 + r-settings-about section>p { 239 239 opacity: 0.6; 240 240 margin-bottom: var(--s3); 241 241 font-size: var(--f5); ··· 425 425 transform: none; 426 426 } 427 427 428 - .search-history-item > span { 428 + .search-history-item>span { 429 429 flex: 1; 430 430 min-width: 0; 431 431 overflow: hidden; ··· 433 433 white-space: nowrap; 434 434 } 435 435 436 - .search-history-item > img { 436 + .search-history-item>img { 437 437 flex-shrink: 0; 438 438 opacity: 0.35; 439 439 } ··· 755 755 border-radius: var(--br-base); 756 756 } 757 757 758 - .bm-import-group + .bm-import-group { 758 + .bm-import-group+.bm-import-group { 759 759 border-top: 1px solid currentColor; 760 760 } 761 761 ··· 776 776 gap: 1px; 777 777 } 778 778 779 - .bm-import-item + .bm-import-item { 779 + .bm-import-item+.bm-import-item { 780 780 border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); 781 781 } 782 782 ··· 793 793 } 794 794 795 795 @media (max-width: 768px) { 796 + 796 797 input, 797 798 textarea, 798 799 select { ··· 816 817 font-size: 12px; 817 818 z-index: 1; 818 819 } 820 + 821 + 822 + /* UI Data Actions */ 823 + ui-data-actions { 824 + display: block; 825 + } 826 + 827 + ui-data-actions>div { 828 + display: flex; 829 + gap: var(--s2); 830 + margin-bottom: var(--s2); 831 + } 832 + 833 + ui-data-actions>p[data-status] { 834 + margin-top: var(--s2); 835 + font-size: var(--f5); 836 + } 837 + 838 + ui-data-actions>p[data-status][data-error] { 839 + color: var(--error); 840 + }