From a801156c56e97469bf6125731418b6e465ca33d4 Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Sun, 1 Feb 2026 10:27:33 +0100 Subject: [PATCH] add location-map and location-picker; improve page create and page listing --- js/components/location-map.js | 242 ++++++++++++++++ js/components/location-picker.js | 417 ++++++++++++++++++++++++++++ js/components/pages/page-create.js | 72 ++++- js/components/pages/page-listing.js | 18 +- js/services/directus.js | 6 +- locales/de.json | 1 + locales/en.json | 1 + locales/fr.json | 1 + 8 files changed, 747 insertions(+), 11 deletions(-) create mode 100644 js/components/location-map.js create mode 100644 js/components/location-picker.js diff --git a/js/components/location-map.js b/js/components/location-map.js new file mode 100644 index 0000000..8fd621f --- /dev/null +++ b/js/components/location-map.js @@ -0,0 +1,242 @@ +/** + * Location Map Component + * Displays a location on an OpenStreetMap map using Leaflet + */ + +const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search' + +class LocationMap extends HTMLElement { + constructor() { + super() + this.map = null + this.marker = null + this.leafletLoaded = false + } + + static get observedAttributes() { + return ['lat', 'lng', 'name', 'postal-code', 'country'] + } + + async connectedCallback() { + await this.loadLeaflet() + this.render() + await this.initMap() + } + + disconnectedCallback() { + if (this.map) { + this.map.remove() + this.map = null + } + } + + attributeChangedCallback() { + if (this.map) { + this.updateMap() + } + } + + async loadLeaflet() { + if (this.leafletLoaded || window.L) { + this.leafletLoaded = true + return + } + + // Load Leaflet CSS + const css = document.createElement('link') + css.rel = 'stylesheet' + css.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css' + css.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=' + css.crossOrigin = '' + document.head.appendChild(css) + + // Load Leaflet JS + await new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js' + script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=' + script.crossOrigin = '' + script.onload = resolve + script.onerror = reject + document.head.appendChild(script) + }) + + this.leafletLoaded = true + } + + render() { + const name = this.getAttribute('name') || '' + const postalCode = this.getAttribute('postal-code') || '' + const country = this.getAttribute('country') || '' + + const locationText = [postalCode, name, country].filter(Boolean).join(', ') + + this.innerHTML = /* html */` +
+
+ 📍 + ${this.escapeHtml(locationText)} +
+
+
+ ` + } + + async initMap() { + const lat = parseFloat(this.getAttribute('lat')) + const lng = parseFloat(this.getAttribute('lng')) + + let coords = null + + if (!isNaN(lat) && !isNaN(lng)) { + coords = [lat, lng] + } else { + // Geocode from name/postal-code/country + coords = await this.geocodeLocation() + } + + if (!coords) { + this.querySelector('.location-map').innerHTML = '

Standort konnte nicht geladen werden

' + return + } + + const mapContainer = this.querySelector('#map') + if (!mapContainer || !window.L) return + + this.map = L.map(mapContainer, { + scrollWheelZoom: false, + dragging: !L.Browser.mobile, + attributionControl: false + }).setView(coords, 13) + + // Custom attribution (OSM required, no Leaflet branding) + L.control.attribution({ + prefix: false + }).addAttribution('© OpenStreetMap').addTo(this.map) + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '' + }).addTo(this.map) + + this.marker = L.marker(coords).addTo(this.map) + + // Invalidate size after render (fixes grey tiles issue) + setTimeout(() => { + this.map.invalidateSize() + }, 100) + } + + async geocodeLocation() { + const name = this.getAttribute('name') || '' + const postalCode = this.getAttribute('postal-code') || '' + const country = this.getAttribute('country') || '' + + const query = [postalCode, name, country].filter(Boolean).join(' ') + if (!query) return null + + try { + const params = new URLSearchParams({ + q: query, + format: 'json', + limit: '1' + }) + + const response = await fetch(`${NOMINATIM_URL}?${params}`, { + headers: { 'User-Agent': 'dgray.io/1.0' } + }) + + const results = await response.json() + if (results.length > 0) { + return [parseFloat(results[0].lat), parseFloat(results[0].lon)] + } + } catch (error) { + console.error('Geocoding failed:', error) + } + + return null + } + + async updateMap() { + if (!this.map) return + + const coords = await this.geocodeLocation() + if (coords) { + this.map.setView(coords, 13) + if (this.marker) { + this.marker.setLatLng(coords) + } + } + } + + escapeHtml(text) { + if (!text) return '' + const div = document.createElement('div') + div.textContent = text + return div.innerHTML + } +} + +customElements.define('location-map', LocationMap) + +const style = document.createElement('style') +style.textContent = /* css */` + location-map { + display: block; + } + + location-map .location-map-container { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; + } + + location-map .location-map-header { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); + } + + location-map .location-icon { + font-size: 1rem; + } + + location-map .location-text { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + } + + location-map .location-map { + height: 200px; + background: var(--color-bg-tertiary); + } + + location-map .map-error { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + } + + /* Fix Leaflet styles for dark mode */ + location-map .leaflet-container { + background: var(--color-bg-tertiary); + } + + location-map .leaflet-control-attribution { + background: var(--color-bg); + color: var(--color-text-muted); + font-size: 10px; + } + + location-map .leaflet-control-attribution a { + color: var(--color-primary); + } +` +document.head.appendChild(style) + +export { LocationMap } diff --git a/js/components/location-picker.js b/js/components/location-picker.js new file mode 100644 index 0000000..073c9b4 --- /dev/null +++ b/js/components/location-picker.js @@ -0,0 +1,417 @@ +import { t, i18n } from '../i18n.js' + +const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search' +const DEBOUNCE_MS = 400 +const MIN_QUERY_LENGTH = 2 + +class LocationPicker extends HTMLElement { + constructor() { + super() + this.suggestions = [] + this.selectedLocation = null + this.loading = false + this.debounceTimer = null + this.isOpen = false + } + + static get observedAttributes() { + return ['value', 'placeholder', 'country'] + } + + connectedCallback() { + this.render() + this.setupEventListeners() + this.unsubscribe = i18n.subscribe(() => this.render()) + } + + disconnectedCallback() { + if (this.unsubscribe) this.unsubscribe() + if (this.debounceTimer) clearTimeout(this.debounceTimer) + document.removeEventListener('click', this.handleOutsideClick) + } + + get value() { + return this.selectedLocation + } + + set value(location) { + this.selectedLocation = location + const input = this.querySelector('.location-input') + if (input && location) { + input.value = this.formatLocationDisplay(location) + } + } + + render() { + const placeholder = this.getAttribute('placeholder') || t('create.locationPlaceholder') || 'Stadt, PLZ oder Adresse' + const currentValue = this.selectedLocation ? this.formatLocationDisplay(this.selectedLocation) : '' + + this.innerHTML = /* html */` +
+
+ + ${this.loading ? '' : ''} +
+
+ ${this.renderSuggestions()} +
+
+ ` + this.setupEventListeners() + } + + renderSuggestions() { + if (this.suggestions.length === 0) return '' + + return this.suggestions.map((loc, index) => /* html */` +
+ 📍 +
+ ${this.escapeHtml(loc.displayName)} + ${this.escapeHtml(loc.detail)} +
+
+ `).join('') + } + + setupEventListeners() { + const input = this.querySelector('.location-input') + const dropdown = this.querySelector('.suggestions-dropdown') + + input?.addEventListener('input', (e) => this.handleInput(e.target.value)) + input?.addEventListener('focus', () => this.handleFocus()) + input?.addEventListener('keydown', (e) => this.handleKeydown(e)) + + dropdown?.querySelectorAll('.suggestion-item').forEach(item => { + item.addEventListener('click', () => { + const index = parseInt(item.dataset.index) + this.selectSuggestion(index) + }) + }) + + this.handleOutsideClick = (e) => { + if (!this.contains(e.target)) { + this.closeSuggestions() + } + } + document.addEventListener('click', this.handleOutsideClick) + } + + handleInput(query) { + if (this.debounceTimer) clearTimeout(this.debounceTimer) + + if (query.length < MIN_QUERY_LENGTH) { + this.suggestions = [] + this.isOpen = false + this.updateDropdown() + return + } + + this.debounceTimer = setTimeout(() => { + this.searchLocations(query) + }, DEBOUNCE_MS) + } + + handleFocus() { + if (this.suggestions.length > 0) { + this.isOpen = true + this.updateDropdown() + } + } + + handleKeydown(e) { + if (e.key === 'Escape') { + this.closeSuggestions() + } else if (e.key === 'ArrowDown' && this.isOpen) { + e.preventDefault() + this.focusNextSuggestion(1) + } else if (e.key === 'ArrowUp' && this.isOpen) { + e.preventDefault() + this.focusNextSuggestion(-1) + } else if (e.key === 'Enter' && this.isOpen) { + e.preventDefault() + const focused = this.querySelector('.suggestion-item.focused') + if (focused) { + const index = parseInt(focused.dataset.index) + this.selectSuggestion(index) + } + } + } + + focusNextSuggestion(direction) { + const items = this.querySelectorAll('.suggestion-item') + if (items.length === 0) return + + const currentFocused = this.querySelector('.suggestion-item.focused') + let nextIndex = 0 + + if (currentFocused) { + const currentIndex = parseInt(currentFocused.dataset.index) + nextIndex = currentIndex + direction + if (nextIndex < 0) nextIndex = items.length - 1 + if (nextIndex >= items.length) nextIndex = 0 + currentFocused.classList.remove('focused') + } + + items[nextIndex]?.classList.add('focused') + } + + async searchLocations(query) { + this.loading = true + this.updateLoadingState() + + try { + const countryCode = this.getAttribute('country') || '' + const params = new URLSearchParams({ + q: query, + format: 'json', + addressdetails: '1', + limit: '6', + 'accept-language': document.documentElement.lang || 'de' + }) + + if (countryCode) { + params.set('countrycodes', countryCode) + } + + const response = await fetch(`${NOMINATIM_URL}?${params}`, { + headers: { + 'User-Agent': 'dgray.io/1.0' + } + }) + + if (!response.ok) throw new Error('Nominatim request failed') + + const results = await response.json() + this.suggestions = results.map(r => this.parseNominatimResult(r)) + this.isOpen = this.suggestions.length > 0 + } catch (error) { + console.error('Location search failed:', error) + this.suggestions = [] + this.isOpen = false + } finally { + this.loading = false + this.updateDropdown() + this.updateLoadingState() + } + } + + parseNominatimResult(result) { + const addr = result.address || {} + + const city = addr.city || addr.town || addr.village || addr.municipality || addr.county || '' + const postalCode = addr.postcode || '' + const country = addr.country || '' + const countryCode = addr.country_code?.toUpperCase() || '' + + let displayName = city + if (postalCode && city) { + displayName = `${postalCode} ${city}` + } else if (postalCode) { + displayName = postalCode + } + + const detail = country + + const region = addr.state || addr.county || '' + + return { + displayName, + detail, + name: city, + postalCode, + region, + country, + countryCode, + lat: parseFloat(result.lat), + lng: parseFloat(result.lon), + raw: result + } + } + + selectSuggestion(index) { + const location = this.suggestions[index] + if (!location) return + + this.selectedLocation = location + const input = this.querySelector('.location-input') + if (input) { + input.value = this.formatLocationDisplay(location) + } + + this.closeSuggestions() + + this.dispatchEvent(new CustomEvent('location-select', { + bubbles: true, + detail: location + })) + } + + formatLocationDisplay(location) { + if (!location) return '' + const parts = [] + if (location.postalCode) parts.push(location.postalCode) + if (location.name) parts.push(location.name) + if (location.countryCode) parts.push(location.countryCode) + return parts.join(', ') + } + + closeSuggestions() { + this.isOpen = false + this.updateDropdown() + } + + updateDropdown() { + const dropdown = this.querySelector('.suggestions-dropdown') + if (dropdown) { + dropdown.className = `suggestions-dropdown ${this.isOpen && this.suggestions.length > 0 ? 'open' : ''}` + dropdown.innerHTML = this.renderSuggestions() + + dropdown.querySelectorAll('.suggestion-item').forEach(item => { + item.addEventListener('click', () => { + const index = parseInt(item.dataset.index) + this.selectSuggestion(index) + }) + }) + } + } + + updateLoadingState() { + const wrapper = this.querySelector('.input-wrapper') + if (!wrapper) return + + let indicator = wrapper.querySelector('.loading-indicator') + if (this.loading && !indicator) { + indicator = document.createElement('span') + indicator.className = 'loading-indicator' + wrapper.appendChild(indicator) + } else if (!this.loading && indicator) { + indicator.remove() + } + } + + escapeHtml(text) { + if (!text) return '' + const div = document.createElement('div') + div.textContent = text + return div.innerHTML + } + + getLocationData() { + if (!this.selectedLocation) return null + return { + name: this.selectedLocation.name, + postal_code: this.selectedLocation.postalCode, + country: this.selectedLocation.countryCode, + lat: this.selectedLocation.lat, + lng: this.selectedLocation.lng + } + } +} + +customElements.define('location-picker', LocationPicker) + +const style = document.createElement('style') +style.textContent = /* css */` + location-picker { + display: block; + position: relative; + } + + location-picker .location-picker { + position: relative; + } + + location-picker .input-wrapper { + position: relative; + } + + location-picker .location-input { + width: 100%; + padding-right: 36px; + } + + location-picker .loading-indicator { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + border: 2px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: location-spinner 0.8s linear infinite; + } + + @keyframes location-spinner { + to { transform: translateY(-50%) rotate(360deg); } + } + + location-picker .suggestions-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 100; + max-height: 280px; + overflow-y: auto; + display: none; + margin-top: 4px; + } + + location-picker .suggestions-dropdown.open { + display: block; + } + + location-picker .suggestion-item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + cursor: pointer; + transition: background-color var(--transition-fast); + } + + location-picker .suggestion-item:hover, + location-picker .suggestion-item.focused { + background: var(--color-bg-secondary); + } + + location-picker .suggestion-icon { + flex-shrink: 0; + font-size: 1rem; + } + + location-picker .suggestion-text { + display: flex; + flex-direction: column; + min-width: 0; + } + + location-picker .suggestion-name { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + location-picker .suggestion-detail { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } +` +document.head.appendChild(style) + +export { LocationPicker } diff --git a/js/components/pages/page-create.js b/js/components/pages/page-create.js index 2b3e072..08f6da1 100644 --- a/js/components/pages/page-create.js +++ b/js/components/pages/page-create.js @@ -3,6 +3,7 @@ import { router } from '../../router.js' import { auth } from '../../services/auth.js' import { directus } from '../../services/directus.js' import { SUPPORTED_CURRENCIES } from '../../services/currency.js' +import '../location-picker.js' const STORAGE_KEY = 'dgray_create_draft' @@ -199,15 +200,10 @@ class PageCreate extends HTMLElement {
- + >

${t('create.locationHint') || 'Stadt oder PLZ eingeben'}

@@ -290,6 +286,14 @@ class PageCreate extends HTMLElement { this.formData.shipping = e.target.checked this.saveDraft() }) + + // Location picker handler + const locationPicker = this.querySelector('#location-picker') + locationPicker?.addEventListener('location-select', (e) => { + this.formData.locationData = e.detail + this.formData.location = e.detail.name + this.saveDraft() + }) imageInput?.addEventListener('change', (e) => this.handleImageSelect(e)) @@ -416,6 +420,14 @@ class PageCreate extends HTMLElement { if (this.formData.shipping) listingData.shipping = this.formData.shipping if (this.formData.moneroAddress) listingData.monero_address = this.formData.moneroAddress + // Handle location - find or create in locations collection + if (this.formData.locationData) { + const locationId = await this.findOrCreateLocation(this.formData.locationData) + if (locationId) { + listingData.location = locationId + } + } + // Add images in junction table format if (imageIds.length > 0) { listingData.images = { @@ -478,6 +490,50 @@ class PageCreate extends HTMLElement { .replace(/^-|-$/g, '') .substring(0, 100) } + + async findOrCreateLocation(locationData) { + if (!locationData || !locationData.name) return null + + try { + // Build filter based on available data + const filter = {} + + if (locationData.postalCode) { + filter.postal_code = { _eq: locationData.postalCode } + } + if (locationData.countryCode) { + filter.country = { _eq: locationData.countryCode } + } + if (locationData.name) { + filter.name = { _eq: locationData.name } + } + + // Only search if we have at least one filter + if (Object.keys(filter).length > 0) { + const existing = await directus.get('/items/locations', { + filter, + limit: 1 + }) + + if (existing.data && existing.data.length > 0) { + return existing.data[0].id + } + } + + // Create new location + const newLocation = await directus.post('/items/locations', { + name: locationData.name || '', + postal_code: locationData.postalCode || null, + region: locationData.region || null, + country: locationData.countryCode || null + }) + + return newLocation.data?.id + } catch (error) { + console.error('Failed to find/create location:', error) + return null + } + } } customElements.define('page-create', PageCreate) diff --git a/js/components/pages/page-listing.js b/js/components/pages/page-listing.js index 9d02de5..180ee62 100644 --- a/js/components/pages/page-listing.js +++ b/js/components/pages/page-listing.js @@ -1,6 +1,7 @@ import { t, i18n } from '../../i18n.js' import { directus } from '../../services/directus.js' import '../chat-widget.js' +import '../location-map.js' class PageListing extends HTMLElement { constructor() { @@ -122,6 +123,17 @@ class PageListing extends HTMLElement { ` : ''} + ${this.listing.location ? ` +
+

${t('listing.location') || 'Standort'}

+ +
+ ` : ''} +