add location-map and location-picker; improve page create and page listing
This commit is contained in:
242
js/components/location-map.js
Normal file
242
js/components/location-map.js
Normal file
@@ -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 */`
|
||||||
|
<div class="location-map-container">
|
||||||
|
<div class="location-map-header">
|
||||||
|
<span class="location-icon">📍</span>
|
||||||
|
<span class="location-text">${this.escapeHtml(locationText)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="location-map" id="map"></div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<p class="map-error">Standort konnte nicht geladen werden</p>'
|
||||||
|
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('© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>').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 }
|
||||||
417
js/components/location-picker.js
Normal file
417
js/components/location-picker.js
Normal file
@@ -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 */`
|
||||||
|
<div class="location-picker">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input location-input"
|
||||||
|
placeholder="${placeholder}"
|
||||||
|
value="${currentValue}"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
${this.loading ? '<span class="loading-indicator"></span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="suggestions-dropdown ${this.isOpen && this.suggestions.length > 0 ? 'open' : ''}">
|
||||||
|
${this.renderSuggestions()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
this.setupEventListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSuggestions() {
|
||||||
|
if (this.suggestions.length === 0) return ''
|
||||||
|
|
||||||
|
return this.suggestions.map((loc, index) => /* html */`
|
||||||
|
<div class="suggestion-item" data-index="${index}">
|
||||||
|
<span class="suggestion-icon">📍</span>
|
||||||
|
<div class="suggestion-text">
|
||||||
|
<span class="suggestion-name">${this.escapeHtml(loc.displayName)}</span>
|
||||||
|
<span class="suggestion-detail">${this.escapeHtml(loc.detail)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).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 }
|
||||||
@@ -3,6 +3,7 @@ import { router } from '../../router.js'
|
|||||||
import { auth } from '../../services/auth.js'
|
import { auth } from '../../services/auth.js'
|
||||||
import { directus } from '../../services/directus.js'
|
import { directus } from '../../services/directus.js'
|
||||||
import { SUPPORTED_CURRENCIES } from '../../services/currency.js'
|
import { SUPPORTED_CURRENCIES } from '../../services/currency.js'
|
||||||
|
import '../location-picker.js'
|
||||||
|
|
||||||
const STORAGE_KEY = 'dgray_create_draft'
|
const STORAGE_KEY = 'dgray_create_draft'
|
||||||
|
|
||||||
@@ -199,15 +200,10 @@ class PageCreate extends HTMLElement {
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="location" data-i18n="create.location">${t('create.location')}</label>
|
<label class="label" for="location" data-i18n="create.location">${t('create.location')}</label>
|
||||||
<input
|
<location-picker
|
||||||
type="text"
|
id="location-picker"
|
||||||
class="input"
|
|
||||||
id="location"
|
|
||||||
name="location"
|
|
||||||
value="${this.escapeHtml(this.formData.location)}"
|
|
||||||
data-i18n-placeholder="create.locationPlaceholder"
|
|
||||||
placeholder="${t('create.locationPlaceholder')}"
|
placeholder="${t('create.locationPlaceholder')}"
|
||||||
>
|
></location-picker>
|
||||||
<p class="field-hint">${t('create.locationHint') || 'Stadt oder PLZ eingeben'}</p>
|
<p class="field-hint">${t('create.locationHint') || 'Stadt oder PLZ eingeben'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -290,6 +286,14 @@ class PageCreate extends HTMLElement {
|
|||||||
this.formData.shipping = e.target.checked
|
this.formData.shipping = e.target.checked
|
||||||
this.saveDraft()
|
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))
|
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.shipping) listingData.shipping = this.formData.shipping
|
||||||
if (this.formData.moneroAddress) listingData.monero_address = this.formData.moneroAddress
|
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
|
// Add images in junction table format
|
||||||
if (imageIds.length > 0) {
|
if (imageIds.length > 0) {
|
||||||
listingData.images = {
|
listingData.images = {
|
||||||
@@ -478,6 +490,50 @@ class PageCreate extends HTMLElement {
|
|||||||
.replace(/^-|-$/g, '')
|
.replace(/^-|-$/g, '')
|
||||||
.substring(0, 100)
|
.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)
|
customElements.define('page-create', PageCreate)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { t, i18n } from '../../i18n.js'
|
import { t, i18n } from '../../i18n.js'
|
||||||
import { directus } from '../../services/directus.js'
|
import { directus } from '../../services/directus.js'
|
||||||
import '../chat-widget.js'
|
import '../chat-widget.js'
|
||||||
|
import '../location-map.js'
|
||||||
|
|
||||||
class PageListing extends HTMLElement {
|
class PageListing extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -122,6 +123,17 @@ class PageListing extends HTMLElement {
|
|||||||
</section>
|
</section>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
${this.listing.location ? `
|
||||||
|
<section class="listing-location-section">
|
||||||
|
<h2>${t('listing.location') || 'Standort'}</h2>
|
||||||
|
<location-map
|
||||||
|
name="${this.escapeHtml(this.listing.location.name || '')}"
|
||||||
|
postal-code="${this.escapeHtml(this.listing.location.postal_code || '')}"
|
||||||
|
country="${this.escapeHtml(this.listing.location.country || '')}"
|
||||||
|
></location-map>
|
||||||
|
</section>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<div class="listing-actions">
|
<div class="listing-actions">
|
||||||
<button class="btn btn-primary btn-lg" id="contact-btn">
|
<button class="btn btn-primary btn-lg" id="contact-btn">
|
||||||
${t('listing.contactSeller')}
|
${t('listing.contactSeller')}
|
||||||
@@ -391,12 +403,14 @@ style.textContent = /* css */`
|
|||||||
}
|
}
|
||||||
|
|
||||||
page-listing .listing-description,
|
page-listing .listing-description,
|
||||||
page-listing .listing-seller {
|
page-listing .listing-seller,
|
||||||
|
page-listing .listing-location-section {
|
||||||
margin-bottom: var(--space-xl);
|
margin-bottom: var(--space-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
page-listing .listing-description h2,
|
page-listing .listing-description h2,
|
||||||
page-listing .listing-seller h2 {
|
page-listing .listing-seller h2,
|
||||||
|
page-listing .listing-location-section h2 {
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,7 +306,11 @@ class DirectusService {
|
|||||||
'images.directus_files_id.id',
|
'images.directus_files_id.id',
|
||||||
'category.id',
|
'category.id',
|
||||||
'category.name',
|
'category.name',
|
||||||
'category.slug'
|
'category.slug',
|
||||||
|
'location.id',
|
||||||
|
'location.name',
|
||||||
|
'location.postal_code',
|
||||||
|
'location.country'
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
"notFound": "Diese Anzeige wurde nicht gefunden.",
|
"notFound": "Diese Anzeige wurde nicht gefunden.",
|
||||||
"backHome": "Zur Startseite",
|
"backHome": "Zur Startseite",
|
||||||
"description": "Beschreibung",
|
"description": "Beschreibung",
|
||||||
|
"location": "Standort",
|
||||||
"seller": "Anbieter",
|
"seller": "Anbieter",
|
||||||
"anonymousSeller": "Anonymer Anbieter",
|
"anonymousSeller": "Anonymer Anbieter",
|
||||||
"memberSince": "Mitglied seit",
|
"memberSince": "Mitglied seit",
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
"notFound": "This listing was not found.",
|
"notFound": "This listing was not found.",
|
||||||
"backHome": "Back to Home",
|
"backHome": "Back to Home",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
|
"location": "Location",
|
||||||
"seller": "Seller",
|
"seller": "Seller",
|
||||||
"anonymousSeller": "Anonymous Seller",
|
"anonymousSeller": "Anonymous Seller",
|
||||||
"memberSince": "Member since",
|
"memberSince": "Member since",
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
"notFound": "Cette annonce n'a pas été trouvée.",
|
"notFound": "Cette annonce n'a pas été trouvée.",
|
||||||
"backHome": "Retour à l'accueil",
|
"backHome": "Retour à l'accueil",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
|
"location": "Emplacement",
|
||||||
"seller": "Vendeur",
|
"seller": "Vendeur",
|
||||||
"anonymousSeller": "Vendeur anonyme",
|
"anonymousSeller": "Vendeur anonyme",
|
||||||
"memberSince": "Membre depuis",
|
"memberSince": "Membre depuis",
|
||||||
|
|||||||
Reference in New Issue
Block a user