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 */`
+
+ `
+ }
+
+ 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 {
@@ -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'}
+
+
+ ` : ''}
+