Refactor: UI.loadLeaflet, leafletMarker, escape, emptyState, locationPicker zentralisiert
- Task 1: UI.loadLeaflet() in ui.js (mit Cluster-Option), lokale _loadLeaflet() in diary/walks/routes/places/poison/events.js entfernt - Task 2: UI.escape() ersetzt lokale _esc()/_escape() in allen 5 Seiten-Modulen - Task 3: UI.emptyState() ersetzt lokale _emptyState() in diary/routes/events.js - Task 4: _fmtDate/_fmtDateShort in walks/poison bewusst behalten (anderes Format), Kommentare ergänzt - Task 5: UI.locationPicker() eingebaut in places/poison/events (ersetzt manuelle GPS-Input-Blöcke) - Task 6: UI.leafletMarker() factory in ui.js, Kreis-divIcon-Blöcke in walks/places/ poison ersetzt; events.js behält Diamant-Marker (andere Form) - SW by-v207, APP_VER 175
This commit is contained in:
parent
066b722c5e
commit
e98ce0d232
9 changed files with 761 additions and 471 deletions
|
|
@ -276,6 +276,9 @@ const UI = (() => {
|
|||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Alias für ältere Aufrufe
|
||||
const escHtml = escape;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HELP TOOLTIP — inline ? Badge mit Klick-Tooltip
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -342,6 +345,567 @@ const UI = (() => {
|
|||
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: `<div style="background:${color};color:#fff;font-size:${Math.round(size * 0.45)}px;font-weight:700;width:${size}px;height:${size}px;border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 5px rgba(0,0,0,0.3);border:2px solid rgba(255,255,255,0.8)">${inner}</div>`,
|
||||
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="28" height="36" viewBox="0 0 32 40"><path d="M16 0C7.163 0 0 7.163 0 16c0 10 16 24 16 24S32 26 32 16C32 7.163 24.837 0 16 0z" fill="#C4843A"/><circle cx="16" cy="16" r="7" fill="white"/></svg>';
|
||||
|
||||
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 = `
|
||||
<div style="position:relative">
|
||||
<div id="${ids.mapWrap}" style="border-radius:var(--radius-md);overflow:hidden;height:200px;background:var(--c-surface-2)"></div>
|
||||
<button type="button" id="${ids.pinHere}" style="
|
||||
position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
|
||||
z-index:1000;background:var(--c-primary);color:#fff;border:none;
|
||||
border-radius:var(--radius-full);padding:6px 14px;font-size:var(--text-xs);
|
||||
font-weight:600;box-shadow:var(--shadow-md);cursor:pointer;
|
||||
display:flex;align-items:center;gap:6px;white-space:nowrap">
|
||||
${_svgIcon('map-pin')} Pin hier setzen
|
||||
</button>
|
||||
</div>
|
||||
<div style="margin-top:var(--space-2)">
|
||||
<div id="${ids.chip}" style="display:none">
|
||||
<div class="diary-location-chip">
|
||||
${_svgIcon('map-pin')}
|
||||
<span id="${ids.chipLabel}"></span>
|
||||
<button type="button" id="${ids.chipClear}" aria-label="Name entfernen">
|
||||
${_svgIcon('x')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
|
||||
<button type="button" class="btn btn-danger btn-sm" id="${ids.coordsClear}">Ort entfernen</button>
|
||||
<button type="button" class="btn btn-secondary flex-1" id="${ids.locBtn}">
|
||||
${_svgIcon('map-pin')}
|
||||
<span id="${ids.locBtnLabel}">GPS → POI suchen</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="${ids.suggestions}" style="display:none;margin-top:var(--space-2)"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary);padding:var(--space-2) 0">Keine Orte in der Nähe gefunden.</p>';
|
||||
} else {
|
||||
sugEl.innerHTML = suggestions.map(s => `
|
||||
<button type="button" class="diary-location-suggestion"
|
||||
data-name="${escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
|
||||
${_svgIcon(_sourceIcon(s.source))}
|
||||
<span>${escape(s.name)}</span>
|
||||
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
|
||||
</button>`).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 `<span class="${cls}" data-star="${idx}" aria-label="${idx} Sterne">★</span>`;
|
||||
}
|
||||
|
||||
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(`<span class="rating-star rating-star--pick${active ? ' rating-star--filled' : ''}" data-pick="${i}" aria-label="${i} Sterne">★</span>`);
|
||||
}
|
||||
return `
|
||||
<div class="rating-widget" id="rw-${containerId}">
|
||||
<div class="rating-pick-stars">${stars.join('')}</div>
|
||||
<textarea class="form-control rating-kommentar" id="rw-komm-${containerId}"
|
||||
placeholder="Kurzer Kommentar (optional, max. 200 Zeichen)"
|
||||
maxlength="200" rows="2">${_myKommentar || ''}</textarea>
|
||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
|
||||
<button class="btn btn-secondary btn-sm" id="rw-cancel-${containerId}">Abbrechen</button>
|
||||
<button class="btn btn-primary btn-sm" id="rw-save-${containerId}"
|
||||
${!(_hoverStar || _myStars) ? 'disabled' : ''}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _render() {
|
||||
const avgLabel = _anzahl > 0
|
||||
? `${_avgStars.toFixed(1)} (${_anzahl} Bewertung${_anzahl !== 1 ? 'en' : ''})`
|
||||
: 'Noch keine Bewertungen';
|
||||
|
||||
const rateHint = isLoggedIn
|
||||
? `<button class="btn btn-ghost btn-sm rating-rate-btn" id="rw-open-${containerId}" style="font-size:var(--text-sm)">
|
||||
${_myStars ? '★ Bewertung ändern' : '★ Bewerten'}
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="rating-display">
|
||||
<div class="rating-stars-avg">${_renderAvg()}</div>
|
||||
<span class="rating-avg-label">${avgLabel}</span>
|
||||
${rateHint}
|
||||
</div>
|
||||
${_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();
|
||||
}
|
||||
|
||||
// Öffentliche API
|
||||
return {
|
||||
toast, modal,
|
||||
|
|
@ -350,8 +914,12 @@ const UI = (() => {
|
|||
emptyState, time,
|
||||
setupPhotoPreview, scrollTop, skeleton,
|
||||
icon: _svgIcon,
|
||||
escape, help,
|
||||
escape, escHtml, help,
|
||||
saveToAlbum,
|
||||
loadLeaflet,
|
||||
leafletMarker,
|
||||
locationPicker,
|
||||
ratingStars,
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue