/* ============================================================
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 = `
${title ? `
` : ''}
${body || ''}
${footer ? `` : ''}
`;
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: `
${cancelText}
${confirmText}
`,
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)
// ----------------------------------------------------------
// ----------------------------------------------------------
// PREVIEW-URL — für lokale Media: _preview.webp statt Original
// ----------------------------------------------------------
// Verwendung:
//
function previewUrl(url) {
if (!url || !url.startsWith('/media/')) return url || '';
return url.replace(/\.(jpe?g|png|gif|webp)$/i, '_preview.webp');
}
// onerror-Handler: bei 404 vom _preview die Original-URL nachladen,
// bei zweitem Fehler das Image komplett ausblenden (kein Browser-Fragezeichen).
// Setzt eine Klasse 'img-broken' damit Container ggf. einen Fallback zeigen können.
function previewFallback(originalUrl) {
if (!originalUrl || !originalUrl.startsWith('/media/')) {
return `this.style.display='none';this.classList.add('img-broken')`;
}
const safe = String(originalUrl).replace(/'/g, "\\'");
return `if(this.src.includes('_preview')&&!this.dataset.fb){this.dataset.fb='1';this.src='${safe}'}else{this.style.display='none';this.classList.add('img-broken')}`;
}
function emptyState({ icon, title, text, action } = {}) {
return `
${icon ? `
${icon}
` : ''}
${title ? `
${title}
` : ''}
${text ? `
${text}
` : ''}
${action ? `
${action}
` : ''}
`;
}
// ----------------------------------------------------------
// ERROR-STATE (dedizierte Error-UI, optional mit Retry-Button)
// ----------------------------------------------------------
// Verwendung:
// container.innerHTML = UI.errorState({
// title: 'Fehler beim Laden',
// message: err.message,
// retry: async () => { await _loadData(); }
// });
let _errorRetryHandlers = new Map();
function errorState({ icon, title = 'Etwas ist schiefgelaufen', message = '', retry = null } = {}) {
const iconHtml = icon || _svgIcon('warning-circle');
const retryId = retry ? `err-retry-${Date.now()}-${Math.random().toString(36).slice(2,7)}` : '';
if (retry) _errorRetryHandlers.set(retryId, retry);
setTimeout(() => {
if (!retryId) return;
const btn = document.getElementById(retryId);
const fn = _errorRetryHandlers.get(retryId);
if (btn && fn) {
btn.addEventListener('click', () => asyncButton(btn, fn));
_errorRetryHandlers.delete(retryId);
}
}, 0);
return `
${iconHtml}
${escHtml(title)}
${message ? `
${escHtml(message)}
` : ''}
${retry ? `
${_svgIcon('arrow-clockwise')} Erneut versuchen
` : ''}
`;
}
// ----------------------------------------------------------
// SKELETON LIST — Karten-Skeleton für Listen-Loading
// ----------------------------------------------------------
// Verwendung: container.innerHTML = UI.skeletonList(5);
function skeletonList(count = 4) {
return `${
Array.from({ length: count }, () => `
`).join('')
}
`;
}
// ----------------------------------------------------------
// MONEY-INPUT (Euro-Input mit Locale-Format)
// ----------------------------------------------------------
// Verwendung: UI.moneyInput({ name: 'betrag', value: 12.50, required: true })
// Rendert: €
function moneyInput({ name, value = '', placeholder = '0,00', required = false, currency = '€' } = {}) {
const val = (value === '' || value == null) ? '' : Number(value).toFixed(2).replace('.', ',');
return `
${currency}
`;
}
// Money parser: Frontend-Helper für Form-Submit
function parseMoney(str) {
if (str == null || str === '') return null;
const cleaned = String(str).replace(',', '.').replace(/[^0-9.]/g, '');
const n = parseFloat(cleaned);
return isNaN(n) ? null : Math.round(n * 100) / 100;
}
// ----------------------------------------------------------
// DATE-PICKER (Wrapper für mit Label)
// ----------------------------------------------------------
// Verwendung: UI.datePicker({ name: 'datum', label: 'Datum', value: '2026-05-27', max: 'today' })
function datePicker({ name, label = '', value = '', min = '', max = '', required = false } = {}) {
const today = new Date().toISOString().slice(0, 10);
const _min = min === 'today' ? today : min;
const _max = max === 'today' ? today : max;
const id = `dp-${name}-${Date.now().toString(36)}`;
return `
${label ? `${escHtml(label)}${required ? ' *' : ''} ` : ''}
`;
}
// ----------------------------------------------------------
// MAP — Leaflet-Karte zentralisiert erstellen
// ----------------------------------------------------------
// Verwendung:
// const map = await UI.map.create('mein-map', { center:[51,10], zoom:6 });
// Optional: { darkFilter: true } für CSS-Filter im Dark-Mode
const map = {
OSM_URL: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
OSM_MAX_ZOOM: 19,
async create(containerId, options = {}) {
const {
center = [51.1657, 10.4515],
zoom = 6,
zoomControl = true,
attributionControl = false,
darkFilter = false,
} = options;
// MapLibre-GL-Seitenkarte (gleicher Style wie die Hauptkarte) — hinter by_map_gl-Flag.
if (_uiUseGL()) {
try {
await loadMapLibreUI();
_uiGL = true;
const isDark = document.documentElement.dataset.theme === 'dark';
return MapGLMini.createMap(containerId, { center, zoom, zoomControl, dark: isDark });
} catch (e) {
console.warn('GL-Seitenkarte nicht verfügbar — Fallback Leaflet:', e);
}
}
_uiGL = false;
await loadLeaflet();
const m = L.map(containerId, { zoomControl, attributionControl }).setView(center, zoom);
// Vektor-Basemap aus eigenen PMTiles (hinter Feature-Flag). Bei Fehler
// (Tiles/Lib nicht da) sauberer Fallback auf den OSM-Raster — Marker etc.
// bleiben in beiden Fällen identisch (reiner Basemap-Tausch).
let usedVector = false;
if (_vectorMapEnabled()) {
try {
await loadProtomaps();
const isDark = document.documentElement.dataset.theme === 'dark';
MapVector.basemapLayer({ dark: isDark }).addTo(m);
if (!attributionControl) {
L.control.attribution({ prefix: false }).addTo(m)
.addAttribution('© OpenStreetMap contributors');
}
usedVector = true;
} catch (e) {
console.warn('Vektor-Basemap nicht verfügbar — Fallback auf Raster:', e);
}
}
if (!usedVector) {
const tiles = L.tileLayer(this.OSM_URL, { maxZoom: this.OSM_MAX_ZOOM }).addTo(m);
if (darkFilter) {
const isDark = document.documentElement.dataset.theme === 'dark';
if (isDark) tiles.getContainer().style.filter = 'brightness(0.7) invert(1) contrast(0.9) hue-rotate(200deg)';
}
}
// Safety-Net: Container-Größe nach Layout neu vermessen. Verhindert
// grau bleibende Bereiche wenn die Karte vor dem finalen Layout erstellt
// wird (z.B. in frisch eingefügten Overlays mit flex:1).
requestAnimationFrame(() => m.invalidateSize());
return m;
},
// SVG-Marker mit eigenem HTML (z.B. mit Pulse-Animation, Rotation, etc.)
svgMarker(lat, lon, html, { size = 32, anchorY = null, className = '' } = {}) {
if (_uiGL && window.MapGLMini) return MapGLMini.svgMarker(lat, lon, html, { size, anchorY });
const icon = L.divIcon({
className,
html,
iconSize: [size, size],
iconAnchor: [size / 2, anchorY != null ? anchorY : size / 2],
});
return L.marker([lat, lon], { icon });
},
// Engine-neutral: Kreis-Marker (Leaflet L.circleMarker bzw. GL-HTML-Punkt).
circleMarker(lat, lon, opts = {}) {
if (_uiGL && window.MapGLMini) return MapGLMini.circleMarker(lat, lon, opts);
return L.circleMarker([lat, lon], opts);
},
// Engine-neutral: FeatureGroup (nur als Bounds-Container für fitBounds genutzt).
featureGroup(markers = []) {
if (_uiGL && window.MapGLMini) return MapGLMini.featureGroup(markers);
return L.featureGroup(markers);
},
// Feature-Flag-Status der Vektor-Basemap (für Karten, die ihren Basemap-Layer
// selbst verwalten, z.B. pages/map.js).
vectorEnabled() { return _vectorMapEnabled(); },
// Lädt protomaps-leaflet + Regeln und liefert den fertigen Vektor-Basemap-Layer
// (Promise). dark=true → dunkles Theme.
async vectorLayer(opts = {}) {
await loadProtomaps();
return MapVector.basemapLayer(opts);
},
};
// ----------------------------------------------------------
// 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));
}
// Datum: "15.03.2026" / "15.03.2026, 14:30"
const _fmtDateNumeric = new Intl.DateTimeFormat('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric'
});
const _fmtDateTimeNumeric = new Intl.DateTimeFormat('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
// Wochentag: "Di." / Tag im Monat: "15"
const _fmtWeekday = new Intl.DateTimeFormat('de-DE', { weekday: 'short' });
return {
relative,
format: d => fmtDate.format(new Date(d)),
formatShort: d => fmtDateShort.format(new Date(d)),
formatDate: d => _fmtDateNumeric.format(new Date(d)), // 15.03.2026
formatDateTime:d => _fmtDateTimeNumeric.format(new Date(d)), // 15.03.2026, 14:30
weekday: d => _fmtWeekday.format(new Date(d)).replace('.', ''),
// ISO-Parser: "2026-03-15" → { year, month, day }
parseISO(str) {
if (!str) return null;
const m = String(str).match(/^(\d{4})-(\d{2})-(\d{2})/);
return m ? { year: +m[1], month: +m[2], day: +m[3] } : null;
},
};
})();
// ----------------------------------------------------------
// TEXT — String-Helper
// ----------------------------------------------------------
const text = {
/** Schneidet str auf maxLen ab und hängt ellipsis an. */
truncate(str, maxLen = 80, ellipsis = '…') {
if (!str) return '';
const s = String(str);
return s.length <= maxLen ? s : s.slice(0, maxLen - ellipsis.length) + ellipsis;
},
/** Slug aus String — für URL-Pfade. */
slug(str) {
return String(str || '')
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
},
};
// ----------------------------------------------------------
// MONEY — Currency-Formatierung (de-DE, EUR)
// ----------------------------------------------------------
const _fmtEur = new Intl.NumberFormat('de-DE', {
style: 'currency', currency: 'EUR',
minimumFractionDigits: 2, maximumFractionDigits: 2,
});
const money = {
/** Formatiert Zahl als "12,34 €" — null/undefined ergibt "—". */
format(value) {
if (value == null || value === '' || isNaN(value)) return '—';
return _fmtEur.format(Number(value));
},
/** Mit Suffix wie "/Jahr". */
formatWithSuffix(value, suffix = '') {
if (value == null || value === '' || isNaN(value)) return '—';
return _fmtEur.format(Number(value)) + (suffix ? ' ' + suffix : '');
},
};
// ----------------------------------------------------------
// 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}
${_svgIcon('x')}
${config.intro}
${config.steps?.length ? `${_buildSteps()}
` : ''}
Mehr erfahren ${_svgIcon('arrow-right')}
`;
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);
});
});
}
// ----------------------------------------------------------
// MapLibre-GL für Seitenkarten (UI.map) — lazy laden + Facade
// ----------------------------------------------------------
let _uiGL = false; // ist die aktuell erstellte UI-Karte GL?
let _maplibreUIPromise = null;
// Gleiche Logik wie pages/map.js _useGL: Staging-Default AN, Prod AUS, by_map_gl überschreibt.
function _uiUseGL() {
try {
const flag = localStorage.getItem('by_map_gl');
if (flag === '1') return true;
if (flag === '0') return false;
return /(^|\.)staging\.banyaro\.app$/.test(location.hostname);
} catch (e) { return false; }
}
function loadMapLibreUI() {
if (_maplibreUIPromise) return _maplibreUIPromise;
const v = '?v=' + (window.APP_VER || '');
if (!document.querySelector('link[href*="maplibre-gl.css"]')) {
const l = document.createElement('link');
l.rel = 'stylesheet'; l.href = '/js/vendor/maplibre-gl.css';
document.head.appendChild(l);
}
const seq = (srcs) => srcs.reduce((p, src) => p.then(() => new Promise((res, rej) => {
if ((src.includes('maplibre-gl.js') && window.maplibregl) ||
(src.includes('pmtiles.js') && window.pmtiles) ||
(src.includes('map-gl-style') && window.MapGLStyle) ||
(src.includes('map-gl-mini') && window.MapGLMini)) return res();
const s = document.createElement('script');
s.src = src + v; s.onload = res; s.onerror = rej;
document.head.appendChild(s);
})), Promise.resolve());
_maplibreUIPromise = seq(['/js/vendor/maplibre-gl.js', '/js/vendor/pmtiles.js', '/js/map-gl-style.js', '/js/map-gl-mini.js']).then(() => {
if (!(window.maplibregl && window.pmtiles && window.MapGLStyle && window.MapGLMini)) throw new Error('MapLibre (UI) nicht geladen');
try { const proto = new pmtiles.Protocol(); maplibregl.addProtocol('pmtiles', proto.tile); } catch (e) { /* evtl. schon registriert */ }
});
return _maplibreUIPromise;
}
// ----------------------------------------------------------
// VEKTOR-BASEMAP (protomaps-leaflet + eigene PMTiles) — lazy laden [DEAKTIVIERT]
// ----------------------------------------------------------
let _protomapsPromise = null;
function loadProtomaps() {
if (_protomapsPromise) return _protomapsPromise;
const v = '?v=' + (window.APP_VER || '');
const loadSeq = (srcs) => srcs.reduce((p, src) => p.then(() => new Promise((res, rej) => {
if ((src.includes('protomaps-leaflet') && window.protomapsL) ||
(src.includes('map-vector') && window.MapVector)) return res();
const s = document.createElement('script');
s.src = src + v;
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
})), Promise.resolve());
// map-vector.js hängt von protomapsL ab → strikt sequenziell laden.
_protomapsPromise = loadSeq(['/js/vendor/protomaps-leaflet.js', '/js/map-vector.js'])
.then(() => {
if (window.protomapsL && window.MapVector) return;
throw new Error('protomaps-leaflet/MapVector nicht geladen');
});
return _protomapsPromise;
}
// Feature-Flag Vektor-Basemap: ?vectormap=1/0 setzt localStorage 'by_vector_map'.
// Default: auf Staging AN (Reifephase), auf Produktion AUS bis zur Freigabe.
// Explizit überschreibbar per Flag (1=an, 0=aus) — gilt auch in der installierten PWA.
// NOTAUS 2026-06-05: Vektor-Basemap deaktiviert — protomaps-leaflet rendert auf dem
// Main-Thread und hängt auf dem Handy zusammen mit der App-Map-Logik die UI auf.
// Erst Performance lösen (z.B. maxzoom begrenzen / Style verschlanken / ggf. MapLibre),
// dann hier wieder freischalten. Greift hart, auch wenn localStorage-Flag='1' gesetzt ist.
const _VECTOR_BASEMAP_KILLED = true;
function _vectorMapEnabled() {
if (_VECTOR_BASEMAP_KILLED) return false;
try {
const u = new URLSearchParams(location.search);
if (u.has('vectormap')) {
localStorage.setItem('by_vector_map', u.get('vectormap') === '0' ? '0' : '1');
}
const flag = localStorage.getItem('by_vector_map');
if (flag === '1') return true;
if (flag === '0') return false;
return /(^|\.)staging\.banyaro\.app$/.test(location.hostname);
} catch (e) { return false; }
}
// ----------------------------------------------------------
// 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 html = `${inner}
`;
if (_uiGL && window.MapGLMini) return MapGLMini.svgMarker(lat, lon, html, { size, anchorY: size / 2 });
const divIcon = L.divIcon({
className: '', html,
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`,
geoInput: `${p}-geo-input`,
geoClear: `${p}-geo-clear`,
geoResults: `${p}-geo-results`,
};
// HTML in den Container rendern
function _render(container) {
container.innerHTML = `
${_svgIcon('map-pin')} Pin hier setzen
${_svgIcon('map-pin')}
${_svgIcon('x')}
Ort entfernen
${_svgIcon('map-pin')}
GPS → POI suchen
`;
}
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 => `
${_svgIcon(_sourceIcon(s.source))}
${escape(s.name)}
${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}
`).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);
// Geocoding-Suche
let _geoTimer = null;
const geoInput = _getEl(ids.geoInput);
const geoClear = _getEl(ids.geoClear);
const geoResults = _getEl(ids.geoResults);
geoInput?.addEventListener('input', () => {
const q = geoInput.value.trim();
if (geoClear) geoClear.style.display = q ? '' : 'none';
clearTimeout(_geoTimer);
if (q.length < 2) { if (geoResults) geoResults.style.display = 'none'; return; }
_geoTimer = setTimeout(async () => {
if (geoResults) {
geoResults.innerHTML = 'Suche…
';
geoResults.style.display = '';
}
try {
const data = await API.get(`/osm/geocode?q=${encodeURIComponent(q)}`);
if (!geoResults) return;
if (!data.length) {
geoResults.innerHTML = 'Keine Ergebnisse
';
return;
}
geoResults.innerHTML = data.map((r, i) => `
${escape(r.name)}
${r.subtitle ? `
${escape(r.subtitle)}
` : ''}
`).join('');
geoResults.querySelectorAll('[data-i]').forEach(el => {
el.addEventListener('pointerdown', e => {
e.preventDefault();
const r = data[+el.dataset.i];
_setCoords(r.lat, r.lon);
_setName(r.name);
if (_map) {
_map.flyTo([r.lat, r.lon], 15, { duration: 0.8 });
_placeMarker(r.lat, r.lon);
}
const lbl = _getEl(ids.locBtnLabel);
if (lbl) lbl.textContent = 'POI suchen';
geoInput.value = '';
if (geoClear) geoClear.style.display = 'none';
geoResults.style.display = 'none';
onSelect?.(_lat, _lon, _name);
});
});
} catch {
if (geoResults) geoResults.innerHTML = 'Suche nicht verfügbar
';
}
}, 400);
});
geoInput?.addEventListener('keydown', e => {
if (e.key === 'Escape') {
geoInput.value = '';
if (geoClear) geoClear.style.display = 'none';
if (geoResults) geoResults.style.display = 'none';
}
});
geoClear?.addEventListener('click', () => {
geoInput.value = '';
geoClear.style.display = 'none';
if (geoResults) geoResults.style.display = 'none';
});
_getEl(ids.mapWrap)?.addEventListener('pointerdown', () => {
if (geoResults) geoResults.style.display = 'none';
geoInput?.blur();
});
}
// 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;
let _ratings = []; // alle Bewertungen (mit Kommentar) für die Liste
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 `
`;
}
function _renderRatingsList() {
const items = _ratings.filter(r => r.kommentar && r.kommentar.trim());
if (!items.length) return '';
return `
${items.map(r => `
${escape(r.user_name || 'Anonym')}
${'★'.repeat(r.stars)}${'★'.repeat(Math.max(0, 5 - r.stars))}
${escape(r.kommentar)}
`).join('')}
`;
}
function _render() {
const avgLabel = _anzahl > 0
? `${_avgStars.toFixed(1)} (${_anzahl} Bewertung${_anzahl !== 1 ? 'en' : ''})`
: 'Noch keine Bewertungen';
const rateHint = isLoggedIn
? `
${_myStars ? '★ Bewertung ändern' : '★ Bewerten'}
`
: '';
container.innerHTML = `
${_renderAvg()}
${avgLabel}
${rateHint}
${_widgetOpen ? _renderWidget() : ''}
${_renderRatingsList()}
`;
// 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 {
await API.ratings.rate(targetType, targetId, _myStars, komm);
_myKommentar = komm || '';
_widgetOpen = false;
_hoverStar = 0;
await _load(); // frische Liste + Durchschnitt inkl. eigener Bewertung
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;
_ratings = Array.isArray(overview.ratings) ? overview.ratings : [];
_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);
});
}
// ----------------------------------------------------------
// NOTE-MODAL — Notiz zu einem beliebigen Objekt (parentType/parentId)
// erstellen/bearbeiten. Zentral, damit nicht jede Seite eine eigene Kopie hat.
// ----------------------------------------------------------
async function noteModal(parentType, parentId, parentLabel, locationName) {
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
overlay.innerHTML = `
${_svgIcon('note-pencil')} Notiz
${escape(parentLabel)}
×
Abbrechen
Speichern
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
try {
const existing = await API.notes.get(parentType, parentId);
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {
await API.notes.create(parentType, parentId, payload);
}
toast.success('Notiz gespeichert.');
_close();
} catch (err) {
toast.error(err.message || 'Fehler beim Speichern.');
setLoading(saveBtn, false);
}
});
}
// Öffentliche API
return {
toast, modal,
noteModal,
setLoading, asyncButton,
formData, setFormError, clearFormErrors,
emptyState, errorState, time, text, money,
previewUrl, previewFallback,
setupPhotoPreview, scrollTop, skeleton, skeletonList,
moneyInput, parseMoney, datePicker,
icon: _svgIcon,
escape, escHtml, help, pageInfo,
saveToAlbum,
loadLeaflet,
leafletMarker,
locationPicker,
map,
ratingStars,
dogChip,
bindDogChip,
};
})();