/* ============================================================ BAN YARO — UI Helpers Alle UI-Interaktionen an einem Ort. Toast, Modal, Loading, Confirm — einmal gebaut, überall nutzbar. ============================================================ */ const UI = (() => { // ---------------------------------------------------------- // PHOSPHOR ICON HELPER — erzeugt SVG-String für Templates // ---------------------------------------------------------- function _svgIcon(name, extraClass = '') { return ``; } // ---------------------------------------------------------- // TOAST // ---------------------------------------------------------- const toast = (() => { const container = () => document.getElementById('toast-container'); function show(message, type = 'default', duration = 3500) { const el = document.createElement('div'); el.className = `toast${type !== 'default' ? ` toast-${type}` : ''}`; const iconName = { success: 'check', danger: 'x', warning: 'warning', info: 'info' }[type]; el.innerHTML = iconName ? `${_svgIcon(iconName)}${message}` : `${message}`; container().appendChild(el); const timer = setTimeout(() => remove(el), duration); el.addEventListener('click', () => { clearTimeout(timer); remove(el); }); } function remove(el) { el.classList.add('removing'); const fallback = setTimeout(() => el.remove(), 300); el.addEventListener('animationend', () => { clearTimeout(fallback); el.remove(); }, { once: true }); } // callable as UI.toast(msg, type) and UI.toast.success(msg) etc. function t(msg, type = 'default', dur) { show(msg, type, dur); } t.show = show; t.success = (msg, dur) => show(msg, 'success', dur); t.error = (msg, dur) => show(msg, 'danger', dur || 5000); t.warning = (msg, dur) => show(msg, 'warning', dur); t.info = (msg, dur) => show(msg, 'info', dur); return t; })(); // ---------------------------------------------------------- // MODAL // ---------------------------------------------------------- const modal = (() => { let _current = null; function open({ title, body, footer, onClose, size } = {}) { close(); // vorheriges schließen const overlay = document.createElement('div'); overlay.className = 'modal-overlay' + (size ? ` modal-overlay--${size}` : ''); overlay.innerHTML = ` `; overlay.querySelector('.modal-close-btn')?.addEventListener('click', close); overlay.addEventListener('click', e => { if (e.target.closest('[data-modal-close]')) close(); }); document.getElementById('modal-container').appendChild(overlay); document.documentElement.classList.add('modal-open'); // Tastatur auf Mobilgeräten: Modal-Höhe begrenzen + fokussiertes Feld scrollen let _vvCleanup = null; const vv = window.visualViewport; const modal = overlay.querySelector('.modal'); if (vv) { const adjust = () => { const visible = vv.height; const offset = vv.offsetTop; const kb = Math.max(0, window.innerHeight - visible - offset); // Overlay-Padding damit Modal nach oben rückt overlay.style.paddingBottom = (kb + 8) + 'px'; // Modal-Höhe hart begrenzen damit modal-body scrollbar bleibt if (modal) modal.style.maxHeight = (visible - 24) + 'px'; }; vv.addEventListener('resize', adjust); vv.addEventListener('scroll', adjust); _vvCleanup = () => { vv.removeEventListener('resize', adjust); vv.removeEventListener('scroll', adjust); overlay.style.paddingBottom = ''; if (modal) modal.style.maxHeight = ''; }; } // Fokussiertes Feld innerhalb modal-body scrollen (iOS scrollIntoView // arbeitet nicht zuverlässig in overflow-Containern) const _onFocusin = e => { const el = e.target; if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA' && el.tagName !== 'SELECT') return; setTimeout(() => { const body = el.closest('.modal-body'); if (!body) { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return; } const elBottom = el.getBoundingClientRect().bottom; const vvBottom = vv ? (vv.offsetTop + vv.height) : window.innerHeight; const gap = elBottom - vvBottom + 56; // 56px Puffer über Tastatur if (gap > 0) body.scrollTop += gap; }, 380); }; overlay.addEventListener('focusin', _onFocusin); // ----------------------------------------------------- // Accessibility: ESC schließt + Focus-Trap // ----------------------------------------------------- const FOCUSABLE_SEL = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; const _getFocusables = () => Array.from(modal?.querySelectorAll(FOCUSABLE_SEL) || []) .filter(el => el.offsetParent !== null || el === document.activeElement); const _prevFocus = document.activeElement; const _onKeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (e.key !== 'Tab') return; const focusables = _getFocusables(); if (!focusables.length) { e.preventDefault(); return; } const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey) { if (document.activeElement === first || !modal.contains(document.activeElement)) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last || !modal.contains(document.activeElement)) { e.preventDefault(); first.focus(); } } }; document.addEventListener('keydown', _onKeydown); // Erstes fokussierbares Element autofokussieren (nach Render) setTimeout(() => { const focusables = _getFocusables(); // Schließen-Button überspringen, falls weitere Elemente vorhanden const target = focusables.find(el => !el.classList.contains('modal-close-btn')) || focusables[0]; target?.focus(); }, 50); _current = { overlay, onClose, _vvCleanup, _onFocusin, _onKeydown, _prevFocus }; return overlay.querySelector('.modal'); } function close() { if (!_current) return; const { onClose, _vvCleanup, _onFocusin, _onKeydown, _prevFocus } = _current; onClose?.(); _vvCleanup?.(); if (_onFocusin) _current.overlay.removeEventListener('focusin', _onFocusin); if (_onKeydown) document.removeEventListener('keydown', _onKeydown); _current.overlay.remove(); document.documentElement.classList.remove('modal-open'); _current = null; // Fokus auf vorheriges Element zurücksetzen (falls noch im DOM) if (_prevFocus && typeof _prevFocus.focus === 'function' && document.body.contains(_prevFocus)) { try { _prevFocus.focus(); } catch (_) {} } // iOS Safari setzt den Zoom nach Input-Fokus nicht zurück — Viewport kurz neu setzen const meta = document.querySelector('meta[name="viewport"]'); if (meta) { const orig = meta.content; meta.content = orig + ',maximum-scale=1'; requestAnimationFrame(() => { meta.content = orig; }); } } // Bestätigungsdialog function confirm({ title, message, confirmText = 'OK', cancelText = 'Abbrechen', danger = false } = {}) { return new Promise(resolve => { const m = open({ title, body: `

${message}

`, footer: ` `, onClose: () => resolve(false), }); m.parentElement.querySelector('#modal-cancel').addEventListener('click', () => { resolve(false); close(); }); m.parentElement.querySelector('#modal-confirm').addEventListener('click', () => { resolve(true); close(); }); }); } return { open, close, confirm }; })(); // ---------------------------------------------------------- // LOADING STATE für Buttons // ---------------------------------------------------------- function setLoading(btn, loading) { if (loading) { btn._originalContent = btn.innerHTML; btn.innerHTML = ''; btn.disabled = true; } else { btn.innerHTML = btn._originalContent || btn.innerHTML; btn.disabled = false; } } // ---------------------------------------------------------- // ASYNC BUTTON: Button-Click → Loader → Ergebnis → Toast // Verwendung: UI.asyncButton(btn, async () => { await API.something() }) // ---------------------------------------------------------- async function asyncButton(btn, fn, { successMsg, errorMsg } = {}) { setLoading(btn, true); try { const result = await fn(); if (successMsg) toast.success(successMsg); return result; } catch (err) { const msg = errorMsg || err.message || 'Ein Fehler ist aufgetreten.'; toast.error(msg); throw err; } finally { setLoading(btn, false); } } // ---------------------------------------------------------- // FORMULAR-HELPER // ---------------------------------------------------------- function formData(form) { const data = {}; new FormData(form).forEach((v, k) => { data[k] = v; }); return data; } function setFormError(form, fieldName, message) { const field = form.querySelector(`[name="${fieldName}"]`); if (!field) return; field.classList.add('is-invalid'); let hint = field.parentElement.querySelector('.form-error'); if (!hint) { hint = document.createElement('span'); hint.className = 'form-error'; field.parentElement.appendChild(hint); } hint.textContent = message; } function clearFormErrors(form) { form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid')); form.querySelectorAll('.form-error').forEach(el => el.remove()); } // ---------------------------------------------------------- // LEERER ZUSTAND (Empty State) // ---------------------------------------------------------- function emptyState({ icon, title, text, action } = {}) { return `
${icon ? `
${icon}
` : ''} ${title ? `
${title}
` : ''} ${text ? `
${text}
` : ''} ${action ? `
${action}
` : ''}
`; } // ---------------------------------------------------------- // DATUM-FORMATIERUNG (Deutsch, relativ) // ---------------------------------------------------------- const time = (() => { const fmt = new Intl.RelativeTimeFormat('de', { numeric: 'auto' }); const fmtDate = new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }); const fmtDateShort = new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'short' }); function relative(dateStr) { const diff = (new Date(dateStr) - Date.now()) / 1000; const abs = Math.abs(diff); if (abs < 60) return fmt.format(Math.round(diff), 'second'); if (abs < 3600) return fmt.format(Math.round(diff / 60), 'minute'); if (abs < 86400)return fmt.format(Math.round(diff / 3600), 'hour'); if (abs < 604800) return fmt.format(Math.round(diff / 86400), 'day'); return fmtDate.format(new Date(dateStr)); } return { relative, format: d => fmtDate.format(new Date(d)), formatShort: d => fmtDateShort.format(new Date(d)), }; })(); // ---------------------------------------------------------- // FOTO-VORSCHAU (Input[type=file] → img) // ---------------------------------------------------------- function setupPhotoPreview(input, imgEl) { input.addEventListener('change', () => { const file = input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = e => { imgEl.src = e.target.result; }; reader.readAsDataURL(file); }); } // ---------------------------------------------------------- // SCROLL TO TOP der Seite // ---------------------------------------------------------- function scrollTop() { document.getElementById('page-content')?.scrollTo({ top: 0, behavior: 'smooth' }); } // ---------------------------------------------------------- // SKELETON LOADER (Platzhalter während Laden) // ---------------------------------------------------------- function skeleton(lines = 3) { return Array.from({ length: lines }, (_, i) => `
`).join('') + ` `; } function escape(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // Alias für ältere Aufrufe const escHtml = escape; // ---------------------------------------------------------- // PAGE INFO — generische Seiten-Hilfe // config: { pageId, title, icon?, intro, steps?: [{icon,title,text}], tip? } // Erstes Öffnen: expandierter Banner. Danach: kleines ? im Header. // ---------------------------------------------------------- function pageInfo(container, config) { const seenKey = 'help_seen_' + config.pageId; const seen = !!localStorage.getItem(seenKey); function _buildSteps() { if (!config.steps?.length) return ''; return config.steps.map(s => `
${s.icon ? `${_svgIcon(s.icon)}` : ''}
${s.title ? `
${s.title}
` : ''}
${s.text}
`).join(''); } function _openModal() { modal.open({ title: `${_svgIcon(config.icon || 'question')} ${config.title}`, body: `

${config.intro}

${config.steps?.length ? `
${_buildSteps()}
` : ''} ${config.tip ? `
${_svgIcon('lightbulb')} ${config.tip}
` : ''}
`, }); } // Kein automatischer absolut-positionierter Trigger mehr. // Aufrufer kann openModal() nutzen und den Button selbst platzieren. // Banner beim ersten Besuch (nicht wenn defaultClosed gesetzt) if (!seen && !config.defaultClosed) { localStorage.setItem(seenKey, '1'); const banner = document.createElement('div'); banner.className = 'pinfo-banner'; banner.innerHTML = `
${_svgIcon(config.icon || 'info')} ${config.title}
${config.intro}
${config.steps?.length ? `
${_buildSteps()}
` : ''} `; banner.querySelector('.pinfo-banner-close').addEventListener('click', () => banner.remove()); banner.querySelector('.pinfo-banner-more').addEventListener('click', () => { banner.remove(); _openModal(); }); container.insertAdjacentElement('afterbegin', banner); } // Inline-Trigger-Button (für Aufrufer zum Einbetten) function makeTriggerBtn() { const btn = document.createElement('button'); btn.className = 'pinfo-trigger-inline'; btn.setAttribute('aria-label', 'Hilfe'); btn.innerHTML = _svgIcon('question'); btn.addEventListener('click', _openModal); return btn; } return { openModal: _openModal, makeTriggerBtn }; } // ---------------------------------------------------------- // HELP TOOLTIP — inline ? Badge mit Klick-Tooltip // ---------------------------------------------------------- function help(text) { return ``; } // Event-Delegation für Help-Tooltips — einmalig registrieren document.addEventListener('click', e => { const btn = e.target.closest('.by-help-btn'); if (!btn) { document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove()); return; } e.stopPropagation(); document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove()); const tip = document.createElement('div'); tip.className = 'by-help-tooltip'; tip.textContent = btn.dataset.help; document.body.appendChild(tip); const r = btn.getBoundingClientRect(); tip.style.top = (r.bottom + window.scrollY + 6) + 'px'; tip.style.left = Math.max(8, r.left + window.scrollX - tip.offsetWidth / 2 + r.width / 2) + 'px'; const maxL = window.innerWidth - tip.offsetWidth - 8; if (parseFloat(tip.style.left) > maxL) tip.style.left = maxL + 'px'; }); // ---------------------------------------------------------- // SAVE TO ALBUM — Foto/Video nach Kamera-Aufnahme in Mediathek // anbieten. Nur bei capture-Aufnahmen aufrufen (nicht Galerie). // Nutzt Web Share API wenn verfügbar, sonst Fallback. // ---------------------------------------------------------- async function saveToAlbum(file) { if (!file) return; // Web Share API mit Datei-Support (iOS Safari 15+, Chrome Android) if (navigator.canShare && navigator.canShare({ files: [file] })) { try { await navigator.share({ files: [file], title: file.name || 'Foto', }); } catch (err) { // AbortError = User hat Sheet geschlossen — kein Fehler anzeigen if (err?.name !== 'AbortError') { console.warn('saveToAlbum share error:', err); } } return; } // Fallback: direkter Download per const url = URL.createObjectURL(file); const a = document.createElement('a'); a.href = url; a.download = file.name || ('foto_' + Date.now() + (file.type === 'video/mp4' ? '.mp4' : '.jpg')); a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 2000); } // ---------------------------------------------------------- // LEAFLET LAZY LOADER — zentrales Laden von Leaflet + MarkerCluster // Dedupliziert: mehrere gleichzeitige Aufrufe warten auf dasselbe Promise. // // Verwendung: // await UI.loadLeaflet(); // nur Leaflet // await UI.loadLeaflet(true); // Leaflet + MarkerCluster // ---------------------------------------------------------- let _leafletPromise = null; function loadLeaflet(withCluster = false) { if (!_leafletPromise) { _leafletPromise = new Promise((resolve, reject) => { // CSS (Duplikat-Check) const cssLoaded = document.querySelector('link[href*="leaflet"]') ? Promise.resolve() : new Promise(res => { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = '/css/leaflet.css'; link.onload = res; link.onerror = res; document.head.appendChild(link); }); cssLoaded.then(() => { if (window.L) { resolve(); return; } if (document.querySelector('script[src*="leaflet.js"]')) { // Script-Tag schon da — warten bis window.L gesetzt ist const poll = setInterval(() => { if (window.L) { clearInterval(poll); resolve(); } }, 50); return; } const s = document.createElement('script'); s.src = '/js/leaflet.js'; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); }); } if (!withCluster) return _leafletPromise; // MarkerCluster zusätzlich laden return _leafletPromise.then(() => { if (window.L && L.markerClusterGroup) return; // CSS if (!document.querySelector('link[href*="MarkerCluster"]')) { ['MarkerCluster.css', 'MarkerCluster.Default.css'].forEach(name => { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = `/css/${name}`; document.head.appendChild(link); }); } // JS if (document.querySelector('script[src*="markercluster"]') || document.querySelector('script[src*="MarkerCluster"]')) return; return new Promise(resolve => { const s = document.createElement('script'); s.src = '/js/leaflet.markercluster.js'; s.onload = resolve; s.onerror = resolve; // graceful degradation document.head.appendChild(s); }); }); } // ---------------------------------------------------------- // LEAFLET MARKER FACTORY — erzeugt einen L.divIcon-Marker // Verwendung: // UI.leafletMarker({ lat, lon, color, icon, size, zIndex }) // Gibt ein L.marker-Objekt zurück, das in eine Karte eingefügt werden kann. // // Params: // color — CSS-Farbe (z.B. 'var(--c-primary)' oder '#22C55E') // icon — HTML-String für das Icon (z.B. UI.icon('dog')) // size — Durchmesser des Kreises in px (default: 32) // label — optionaler Text der im Kreis angezeigt wird // ---------------------------------------------------------- function leafletMarker({ lat, lon, color = 'var(--c-primary)', icon = '', size = 32, label = '' } = {}) { const inner = label || icon; const divIcon = L.divIcon({ className: '', html: `
${inner}
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); return L.marker([lat, lon], { icon: divIcon }); } // ---------------------------------------------------------- // LOCATION PICKER — zentrale Karten-Komponente // Rendert Leaflet-Karte + GPS-Button + Ort-Chip in das Element // mit der angegebenen containerId. // // Verwendung: // const picker = UI.locationPicker({ // containerId: 'my-map-wrap', // onSelect(lat, lon, name) { ... } // }); // picker.setValue(lat, lon, name); // vorhandene Werte laden // picker.getValue(); // → { lat, lon, name } // ---------------------------------------------------------- function locationPicker({ containerId, onSelect } = {}) { // Interne State-Variablen let _lat = null; let _lon = null; let _name = null; let _map = null; let _marker = null; const _pinSvg = ''; function _sourceIcon(source) { if (source === 'places') return 'star'; if (source === 'osm') return 'map-pin'; return 'map-trifold'; } // IDs werden mit containerId geprefixt um Konflikte zu vermeiden const p = containerId.replace(/[^a-z0-9]/gi, '-'); const ids = { mapWrap: `${p}-map`, chip: `${p}-chip-wrap`, chipLabel: `${p}-chip-label`, chipClear: `${p}-chip-clear`, locBtn: `${p}-loc-btn`, locBtnLabel: `${p}-loc-btn-label`, coordsClear: `${p}-coords-clear`, suggestions: `${p}-suggestions`, pinHere: `${p}-pin-here`, }; // HTML in den Container rendern function _render(container) { container.innerHTML = `
`; } function _getEl(id) { return document.getElementById(id); } function _mkIcon() { return L.divIcon({ html: _pinSvg, className: '', iconSize: [28, 36], iconAnchor: [14, 36] }); } function _placeMarker(lat, lon) { if (_marker) { _marker.setLatLng([lat, lon]); return; } _marker = L.marker([lat, lon], { draggable: true, icon: _mkIcon() }).addTo(_map); _marker.on('dragend', () => { const p2 = _marker.getLatLng(); _lat = p2.lat; _lon = p2.lng; const lbl = _getEl(ids.locBtnLabel); if (lbl) lbl.textContent = 'POI suchen'; onSelect?.(_lat, _lon, _name); }); } function _setCoords(lat, lon) { _lat = lat; _lon = lon; } function _setName(name) { _name = name; const chipLbl = _getEl(ids.chipLabel); const chipWrap = _getEl(ids.chip); const sugEl = _getEl(ids.suggestions); if (chipLbl) chipLbl.textContent = name; if (chipWrap) chipWrap.style.display = ''; if (sugEl) sugEl.style.display = 'none'; onSelect?.(_lat, _lon, _name); } function _loadLeafletLocal() { if (window.L) return Promise.resolve(); return new Promise((resolve, reject) => { const cssLoaded = document.querySelector('link[href*="leaflet"]') ? Promise.resolve() : new Promise(res => { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = '/css/leaflet.css'; link.onload = res; link.onerror = res; document.head.appendChild(link); }); cssLoaded.then(() => { if (document.querySelector('script[src*="leaflet.js"]')) { resolve(); return; } const s = document.createElement('script'); s.src = '/js/leaflet.js'; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); }); } function _initMap() { _loadLeafletLocal().then(() => { setTimeout(() => { const mapEl = _getEl(ids.mapWrap); if (!mapEl) return; const lat = _lat || 48.0; const lon = _lon || 11.9; const zoom = _lat ? 15 : 7; _map = L.map(ids.mapWrap, { zoomControl: true, attributionControl: false, dragging: true, scrollWheelZoom: false, }).setView([lat, lon], zoom); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }) .addTo(_map); _map.invalidateSize(); setTimeout(() => _map?.invalidateSize(), 300); if (_lat) _placeMarker(lat, lon); _map.on('click', e => { _setCoords(e.latlng.lat, e.latlng.lng); _placeMarker(_lat, _lon); const lbl = _getEl(ids.locBtnLabel); if (lbl) lbl.textContent = 'POI suchen'; onSelect?.(_lat, _lon, _name); }); _getEl(ids.pinHere)?.addEventListener('click', () => { const c = _map.getCenter(); _setCoords(c.lat, c.lng); _placeMarker(c.lat, c.lng); const lbl = _getEl(ids.locBtnLabel); if (lbl) lbl.textContent = 'POI suchen'; onSelect?.(_lat, _lon, _name); }); }, 150); }); } function _bindEvents() { // Chip-Name entfernen _getEl(ids.chipClear)?.addEventListener('click', () => { _name = null; const chipWrap = _getEl(ids.chip); if (chipWrap) chipWrap.style.display = 'none'; onSelect?.(_lat, _lon, null); }); // Koordinaten + Name komplett entfernen (Zwei-Klick) const coordsClearBtn = _getEl(ids.coordsClear); let _clearPending = false; coordsClearBtn?.addEventListener('click', () => { if (!_clearPending) { _clearPending = true; coordsClearBtn.textContent = 'Wirklich entfernen?'; coordsClearBtn.style.color = 'var(--c-danger)'; setTimeout(() => { _clearPending = false; if (coordsClearBtn) { coordsClearBtn.textContent = 'Ort entfernen'; coordsClearBtn.style.color = ''; } }, 3000); return; } _clearPending = false; coordsClearBtn.textContent = 'Ort entfernen'; coordsClearBtn.style.color = ''; _lat = null; _lon = null; _name = null; const chipWrap = _getEl(ids.chip); const sugEl = _getEl(ids.suggestions); const lbl = _getEl(ids.locBtnLabel); if (chipWrap) chipWrap.style.display = 'none'; if (sugEl) sugEl.style.display = 'none'; if (lbl) lbl.textContent = 'GPS → POI suchen'; if (_marker) { _marker.remove(); _marker = null; } if (_map) _map.setView([48.0, 11.9], 7); onSelect?.(null, null, null); }); // GPS-Button + POI-Suche async function _showSuggestions() { const btn = _getEl(ids.locBtn); if (btn) setLoading(btn, true); try { let lat = _lat, lon = _lon; if (lat == null || lon == null) { const pos = await API.getLocation({ enableHighAccuracy: true }); lat = pos.lat; lon = pos.lon; _setCoords(lat, lon); if (_map) { _map.setView([lat, lon], 15); _placeMarker(lat, lon); } const lbl = _getEl(ids.locBtnLabel); if (lbl) lbl.textContent = 'POI suchen'; } let suggestions = []; try { suggestions = await API.walks.nearby(lat, lon); } catch {} const sugEl = _getEl(ids.suggestions); if (!sugEl) return; if (!suggestions.length) { sugEl.innerHTML = '

Keine Orte in der Nähe gefunden.

'; } else { sugEl.innerHTML = suggestions.map(s => ` `).join(''); sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => { el.addEventListener('click', () => { const slat = parseFloat(el.dataset.lat); const slon = parseFloat(el.dataset.lon); _setCoords(slat, slon); _setName(el.dataset.name); if (_map) { _map.setView([slat, slon], 16); _placeMarker(slat, slon); } }); }); } sugEl.style.display = ''; onSelect?.(_lat, _lon, _name); } catch (err) { toast.error(err?.message?.includes('GPS') || _lat == null ? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.'); } finally { if (btn) setLoading(btn, false); } } _getEl(ids.locBtn)?.addEventListener('click', _showSuggestions); } // Container initialisieren const container = document.getElementById(containerId); if (!container) { console.warn('UI.locationPicker: containerId nicht gefunden:', containerId); return { getValue: () => ({ lat: null, lon: null, name: null }), setValue: () => {} }; } _render(container); _bindEvents(); _initMap(); // Öffentliche API des Pickers return { getValue() { return { lat: _lat, lon: _lon, name: _name }; }, setValue(lat, lon, name) { _lat = lat != null ? parseFloat(lat) : null; _lon = lon != null ? parseFloat(lon) : null; _name = name || null; // Chip aktualisieren const chipLbl = _getEl(ids.chipLabel); const chipWrap = _getEl(ids.chip); const lbl = _getEl(ids.locBtnLabel); if (chipLbl) chipLbl.textContent = _name || ''; if (chipWrap) chipWrap.style.display = _name ? '' : 'none'; if (lbl) lbl.textContent = _lat ? 'POI suchen' : 'GPS → POI suchen'; // Karte anpassen wenn bereits initialisiert if (_map && _lat) { _map.setView([_lat, _lon], 15); _placeMarker(_lat, _lon); } }, }; } // ---------------------------------------------------------- // RATING STARS — wiederverwendbare Bewertungskomponente // Verwendung: UI.ratingStars({ containerId, targetType, targetId, isLoggedIn }) // Rendert Sterne-Anzeige + Inline-Widget zum Bewerten // ---------------------------------------------------------- function ratingStars({ containerId, targetType, targetId, isLoggedIn }) { const container = document.getElementById(containerId); if (!container) return; let _avgStars = 0; let _anzahl = 0; let _myStars = null; let _myKommentar = ''; let _hoverStar = 0; let _widgetOpen = false; function _starHTML(filled, half = false, idx = 0) { const cls = filled ? 'rating-star rating-star--filled' : (half ? 'rating-star rating-star--half' : 'rating-star rating-star--empty'); return ``; } function _renderAvg() { const stars = []; for (let i = 1; i <= 5; i++) { const diff = _avgStars - (i - 1); if (diff >= 1) stars.push(_starHTML(true, false, i)); else if (diff >= 0.4) stars.push(_starHTML(false, true, i)); else stars.push(_starHTML(false, false, i)); } return stars.join(''); } function _renderWidget() { const stars = []; for (let i = 1; i <= 5; i++) { const active = (_hoverStar || _myStars || 0) >= i; stars.push(``); } return `
${stars.join('')}
`; } function _render() { const avgLabel = _anzahl > 0 ? `${_avgStars.toFixed(1)} (${_anzahl} Bewertung${_anzahl !== 1 ? 'en' : ''})` : 'Noch keine Bewertungen'; const rateHint = isLoggedIn ? `` : ''; container.innerHTML = `
${_renderAvg()}
${avgLabel} ${rateHint}
${_widgetOpen ? _renderWidget() : ''} `; // Events document.getElementById(`rw-open-${containerId}`)?.addEventListener('click', () => { _widgetOpen = true; _render(); _bindWidget(); }); } function _bindWidget() { const widget = document.getElementById(`rw-${containerId}`); if (!widget) return; // Hover widget.querySelectorAll('[data-pick]').forEach(el => { el.addEventListener('mouseenter', () => { _hoverStar = parseInt(el.dataset.pick); _render(); _bindWidget(); }); el.addEventListener('mouseleave', () => { _hoverStar = 0; _render(); _bindWidget(); }); el.addEventListener('click', () => { _myStars = parseInt(el.dataset.pick); _hoverStar = 0; _render(); _bindWidget(); const saveBtn = document.getElementById(`rw-save-${containerId}`); if (saveBtn) saveBtn.disabled = false; }); // Touch el.addEventListener('touchend', (e) => { e.preventDefault(); _myStars = parseInt(el.dataset.pick); _hoverStar = 0; _render(); _bindWidget(); const saveBtn = document.getElementById(`rw-save-${containerId}`); if (saveBtn) saveBtn.disabled = false; }); }); document.getElementById(`rw-cancel-${containerId}`)?.addEventListener('click', () => { _widgetOpen = false; _hoverStar = 0; _render(); }); document.getElementById(`rw-save-${containerId}`)?.addEventListener('click', async () => { if (!_myStars) return; const saveBtn = document.getElementById(`rw-save-${containerId}`); if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = '…'; } const komm = document.getElementById(`rw-komm-${containerId}`)?.value?.trim() || null; try { const res = await API.ratings.rate(targetType, targetId, _myStars, komm); _avgStars = res.bewertung; _anzahl = res.anz_bewertungen; _myKommentar = komm || ''; _widgetOpen = false; _hoverStar = 0; _render(); toast.success('Bewertung gespeichert!'); } catch (err) { toast.error(err?.message || 'Fehler beim Speichern.'); if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; } } }); } async function _load() { try { const [overview, mine] = await Promise.all([ API.ratings.list(targetType, targetId), isLoggedIn ? API.ratings.mine(targetType, targetId) : Promise.resolve({ stars: null, kommentar: null }), ]); _avgStars = overview.bewertung || 0; _anzahl = overview.anz_bewertungen || 0; _myStars = mine.stars || null; _myKommentar = mine.kommentar || ''; } catch (e) { // silent – Bewertungen sind optional } _render(); } _load(); } function dogChip(appState) { const dog = appState?.activeDog; const dogs = appState?.dogs || []; if (!dog) return ''; const av = dog.foto_url ? `` : ``; const sw = dogs.length > 1 ? `` : ''; return `
${av}${escape(dog.name)}${sw}
`; } function bindDogChip(container, appState) { if ((appState?.dogs?.length || 0) < 2) return; container.querySelector('[data-dog-chip]')?.addEventListener('click', () => { const dogs = appState.dogs; const next = dogs.find(d => d.id !== appState.activeDog?.id) || dogs[0]; if (next) App.setActiveDog(next.id); }); } // Öffentliche API return { toast, modal, setLoading, asyncButton, formData, setFormError, clearFormErrors, emptyState, time, setupPhotoPreview, scrollTop, skeleton, icon: _svgIcon, escape, escHtml, help, pageInfo, saveToAlbum, loadLeaflet, leafletMarker, locationPicker, ratingStars, dogChip, bindDogChip, dogChip, bindDogChip, }; })();