banyaro/backend/static/js/ui.js
rene 297bd22f96 Bündel 2: Zentrale Helper für DRY-Cleanup, SW by-v1114
NEUE BACKEND-MODULE:

math_utils.py
- haversine_km(lat1, lon1, lat2, lon2) — Distanz in km
- haversine_m(...) — Convenience-Wrapper in Metern
- bbox_deg_from_km(lat, radius_km) — Bounding-Box-Approximation
  für SQL-Vorfilter (statt Haversine im Python-Loop)

config.py
- DB_PATH, MEDIA_DIR, BREEDER_DOCS_DIR, SCANINPUT_DIR
- API_TIMEOUT_SHORT (5s) / DEFAULT (10s) / LONG (30s)
- HTTP_USER_AGENT, HTTP_HEADERS

errors.py
- not_found(msg), forbidden(msg), bad_request(msg), unauthorized(msg)
- conflict(msg), too_many_requests(msg, retry_after), service_unavailable(msg)
- require_or_404(row, msg) — Convenience-Helper

UI.JS ERWEITERUNGEN:

UI.time erweitert:
- formatDate(d)     → "15.03.2026"
- formatDateTime(d) → "15.03.2026, 14:30"
- weekday(d)        → "Di"
- parseISO(str)     → {year, month, day}

UI.text (neu):
- truncate(str, maxLen, ellipsis='…')
- slug(str) — URL-Slug aus String (mit DE-Umlauten)

UI.money (neu):
- format(value) → "12,34 €" (de-DE, EUR)
- formatWithSuffix(value, '/Jahr')

HAVERSINE-MIGRATION (13 Backend-Routen):
alerts.py, services.py, places.py, events.py, diary.py, playdate.py,
lost.py, poison.py, adoption.py, gassi_zeiten.py, sitting.py, routen.py,
walks.py

- Alle lokalen def _haversine/haversine_km entfernt
- Aufrufe ersetzt durch haversine_km/haversine_m je nach Einheit
- from math_utils import haversine_km|haversine_m in jeder Datei

Tests 19/19 grün.

Hinweis: Migrationen für MEDIA_DIR (19 Stellen), API-Timeouts (12),
Date-Formatter im Frontend (24) und UI.text.truncate (5) sind als
Folge-Sprints möglich. Helper sind verfügbar.
2026-05-27 11:19:06 +02:00

1351 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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 `<svg class="ph-icon${extraClass ? ' ' + extraClass : ''}" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
}
// ----------------------------------------------------------
// 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)}<span>${message}</span>`
: `<span>${message}</span>`;
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 = `
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-handle"></div>
${title ? `
<div class="modal-header">
<span class="modal-title">${title}</span>
<button class="btn btn-ghost btn-icon modal-close-btn" aria-label="Schließen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg></button>
</div>
` : ''}
<div class="modal-body">${body || ''}</div>
${footer ? `<div class="modal-footer">${footer}</div>` : ''}
</div>
`;
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: `<p style="color:var(--c-text-secondary)">${message}</p>`,
footer: `
<button class="btn btn-secondary" id="modal-cancel">${cancelText}</button>
<button class="btn ${danger ? 'btn-danger' : 'btn-primary'}" id="modal-confirm">
${confirmText}
</button>
`,
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 = '<span class="spinner spinner-sm"></span>';
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:
// <img src="${UI.previewUrl(profile.foto_url)}"
// onerror="${UI.previewFallback(profile.foto_url)}">
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 `
<div class="empty-state">
${icon ? `<div class="empty-state-icon">${icon}</div>` : ''}
${title ? `<div class="empty-state-title">${title}</div>` : ''}
${text ? `<div class="empty-state-text">${text}</div>` : ''}
${action ? `<div class="mt-4">${action}</div>` : ''}
</div>
`;
}
// ----------------------------------------------------------
// 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 `
<div class="empty-state" role="alert">
<div class="empty-state-icon text-danger">${iconHtml}</div>
<div class="empty-state-title">${escHtml(title)}</div>
${message ? `<div class="empty-state-text">${escHtml(message)}</div>` : ''}
${retry ? `<div class="mt-4">
<button id="${retryId}" class="btn btn-primary btn-sm">
${_svgIcon('arrow-clockwise')} Erneut versuchen
</button>
</div>` : ''}
</div>
`;
}
// ----------------------------------------------------------
// SKELETON LIST — Karten-Skeleton für Listen-Loading
// ----------------------------------------------------------
// Verwendung: container.innerHTML = UI.skeletonList(5);
function skeletonList(count = 4) {
return `<div class="flex-col-gap-3">${
Array.from({ length: count }, () => `
<div class="card" style="padding:var(--space-3);display:flex;gap:var(--space-3);align-items:center">
<div style="width:48px;height:48px;border-radius:50%;
background:var(--c-surface-2);animation:skeleton-pulse 1.5s ease infinite;
flex-shrink:0"></div>
<div class="flex-1-min">
<div style="height:14px;width:60%;background:var(--c-surface-2);
border-radius:var(--radius-sm);margin-bottom:var(--space-2);
animation:skeleton-pulse 1.5s ease infinite"></div>
<div style="height:10px;width:85%;background:var(--c-surface-2);
border-radius:var(--radius-sm);
animation:skeleton-pulse 1.5s ease infinite"></div>
</div>
</div>
`).join('')
}</div>
<style>@keyframes skeleton-pulse{0%,100%{opacity:1}50%{opacity:0.4}}</style>`;
}
// ----------------------------------------------------------
// MONEY-INPUT (Euro-Input mit Locale-Format)
// ----------------------------------------------------------
// Verwendung: UI.moneyInput({ name: 'betrag', value: 12.50, required: true })
// Rendert: <div class="money-input-wrap"><span>€</span><input type="number" ...></div>
function moneyInput({ name, value = '', placeholder = '0,00', required = false, currency = '€' } = {}) {
const val = (value === '' || value == null) ? '' : Number(value).toFixed(2).replace('.', ',');
return `
<div style="position:relative;display:flex;align-items:center">
<span style="position:absolute;left:var(--space-3);color:var(--c-text-muted);
pointer-events:none;font-weight:600">${currency}</span>
<input type="text" inputmode="decimal" name="${escHtml(name)}"
class="form-control" placeholder="${escHtml(placeholder)}"
style="padding-left:calc(var(--space-3) + 18px)"
pattern="[0-9]+([,.][0-9]{1,2})?"
value="${val}" ${required ? 'required' : ''}>
</div>
`;
}
// 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 <input type=date> 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 ? `<label for="${id}" class="label-block">${escHtml(label)}${required ? ' *' : ''}</label>` : ''}
<input type="date" id="${id}" name="${escHtml(name)}" class="form-control"
value="${escHtml(value || '')}"
${_min ? `min="${escHtml(_min)}"` : ''}
${_max ? `max="${escHtml(_max)}"` : ''}
${required ? '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 = {}) {
await loadLeaflet();
const {
center = [51.1657, 10.4515],
zoom = 6,
zoomControl = true,
attributionControl = false,
darkFilter = false,
} = options;
const m = L.map(containerId, { zoomControl, attributionControl }).setView(center, zoom);
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)';
}
return m;
},
// SVG-Marker mit eigenem HTML (z.B. mit Pulse-Animation, Rotation, etc.)
svgMarker(lat, lon, html, { size = 32, anchorY = null, className = '' } = {}) {
const icon = L.divIcon({
className,
html,
iconSize: [size, size],
iconAnchor: [size / 2, anchorY != null ? anchorY : size / 2],
});
return L.marker([lat, lon], { icon });
},
};
// ----------------------------------------------------------
// 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) => `
<div style="height:${i === 0 ? 20 : 14}px; width:${70 + Math.random() * 30}%;
background:var(--c-surface-2); border-radius:var(--radius-sm);
margin-bottom:var(--space-2); animation:skeleton-pulse 1.5s ease infinite">
</div>
`).join('') + `
<style>
@keyframes skeleton-pulse {
0%,100% { opacity:1 } 50% { opacity:0.4 }
}
</style>
`;
}
function escape(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// 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 => `
<div class="pinfo-step">
${s.icon ? `<span class="pinfo-step-icon">${_svgIcon(s.icon)}</span>` : ''}
<div>
${s.title ? `<div class="pinfo-step-title">${s.title}</div>` : ''}
<div class="pinfo-step-text">${s.text}</div>
</div>
</div>`).join('');
}
function _openModal() {
modal.open({
title: `${_svgIcon(config.icon || 'question')} ${config.title}`,
body: `
<div class="pinfo-modal">
<p class="pinfo-intro">${config.intro}</p>
${config.steps?.length ? `<div class="pinfo-steps">${_buildSteps()}</div>` : ''}
${config.tip ? `<div class="pinfo-tip">${_svgIcon('lightbulb')} ${config.tip}</div>` : ''}
</div>`,
});
}
// 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 = `
<div class="pinfo-banner-head">
<span class="pinfo-banner-icon">${_svgIcon(config.icon || 'info')}</span>
<span class="pinfo-banner-title">${config.title}</span>
<button class="pinfo-banner-close" aria-label="Schließen">${_svgIcon('x')}</button>
</div>
<div class="pinfo-banner-intro">${config.intro}</div>
${config.steps?.length ? `<div class="pinfo-steps pinfo-steps--compact">${_buildSteps()}</div>` : ''}
<button class="pinfo-banner-more">Mehr erfahren ${_svgIcon('arrow-right')}</button>
`;
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 `<button class="by-help-btn" data-help="${escape(text)}" aria-label="Hilfe" type="button">
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px">
<use href="/icons/phosphor.svg#question"></use>
</svg>
</button>`;
}
// 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 <a download> 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 <a download>
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: `<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();
}
function dogChip(appState) {
const dog = appState?.activeDog;
const dogs = appState?.dogs || [];
if (!dog) return '';
const av = dog.foto_url
? `<img src="${escape(dog.foto_url)}" style="width:22px;height:22px;border-radius:50%;object-fit:cover;flex-shrink:0">`
: `<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary);flex-shrink:0"><use href="/icons/phosphor.svg#dog"></use></svg>`;
const sw = dogs.length > 1
? `<svg class="ph-icon" style="width:13px;height:13px;color:var(--c-text-muted);margin-left:2px"><use href="/icons/phosphor.svg#arrows-left-right"></use></svg>` : '';
return `<div class="by-dog-chip" data-dog-chip style="display:inline-flex;align-items:center;gap:6px;
padding:4px 10px 4px 6px;background:var(--c-surface-2);border:1px solid var(--c-border);
border-radius:100px;font-size:var(--text-xs);font-weight:600;color:var(--c-text);
${dogs.length > 1 ? 'cursor:pointer' : ''};max-width:fit-content">${av}<span>${escape(dog.name)}</span>${sw}</div>`;
}
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, 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,
};
})();