Wiederverwendbarer UI.noteMediaAttacher für beide Notiz-Stellen (UI.noteModal
+ Notizblock-Seite). note_media-Tabelle + POST/DELETE /api/notes/{id}/media
(vor der gierigen /{parent_type}/{parent_id}-Route). Audio per MediaRecorder,
serverseitig nach m4a/AAC transkodiert (ffmpeg) — iOS spielt Chrome-Opus-webm
nicht ab. UI.lightbox global eingeführt. Mikrofon-Policy microphone=(self) +
CSP media-src 'self' blob:, Datenschutz v6. Disk-Cleanup für note_media bei
Notiz-, Account- und Admin-User-Delete. Reine Medien-Notiz ohne Text erlaubt.
noteModal-Bug gefixt: notes.get() liefert Array -> existing[0] statt
existing?.id (verhinderte Bearbeiten, erzeugte Duplikate). 12 neue Tests.
admin.py enthält außerdem KI-Vision-Statusfelder aus paralleler Arbeit
(nicht sauber trennbar ohne interaktives Staging).
2151 lines
96 KiB
JavaScript
2151 lines
96 KiB
JavaScript
/* ============================================================
|
||
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 = {}) {
|
||
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('© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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. Akzeptiert (lat, lon, opts) ODER ([lat,lon], opts) (Leaflet-Stil).
|
||
circleMarker(lat, lon, opts = {}) {
|
||
if (Array.isArray(lat)) { opts = lon || {}; lon = lat[1]; lat = lat[0]; }
|
||
else if (lat && lat.lat != null) { opts = lon || {}; lon = lat.lng; lat = lat.lat; }
|
||
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);
|
||
},
|
||
|
||
// Engine-neutral: Polylinie (Route/Track).
|
||
polyline(latlngs, opts = {}) {
|
||
if (_uiGL && window.MapGLMini) return MapGLMini.polyline(latlngs, opts);
|
||
return L.polyline(latlngs, opts);
|
||
},
|
||
|
||
// Engine-neutral: Cluster-/Marker-Gruppe (GL: ohne Clustering, einfache Gruppe).
|
||
clusterGroup(opts = {}) {
|
||
if (_uiGL && window.MapGLMini) return MapGLMini.clusterGroup();
|
||
return L.markerClusterGroup(opts);
|
||
},
|
||
|
||
// 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);
|
||
},
|
||
|
||
// Rendert für einen Track (Array {lat,lon}) ein PNG-Vorschaubild MIT Basemap
|
||
// (gleicher GL-Style wie die echte Karte) und liefert eine data-URL.
|
||
// EIN einziger Offscreen-GL-Kontext, serielle Verarbeitung, Cache pro key —
|
||
// so bekommt jede Routenkarte ihren geografischen Kontext, ohne das WebGL-
|
||
// Kontextlimit zu sprengen (Problem bei N Live-Mini-Karten auf iOS).
|
||
// Liefert null wenn GL aus ist (Aufrufer nutzt dann seinen SVG-Fallback).
|
||
snapshot(track, opts = {}) { return _glSnapshot(track, 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) => `
|
||
<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, '&')
|
||
.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 => `
|
||
<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);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// 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: GL-Default auf allen deployten Hosts AN, by_map_gl überschreibt.
|
||
function _uiUseGL() {
|
||
try {
|
||
const flag = localStorage.getItem('by_map_gl');
|
||
if (flag === '1') return true;
|
||
if (flag === '0') return false;
|
||
// GL-Default auf allen deployten Hosts (Prod + Staging); localhost bleibt OSM. Prod-Freigabe 2026-06-05.
|
||
return /(^|\.)banyaro\.(app|de)$/.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-offline') && window.MapOffline) ||
|
||
(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-offline.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 */ }
|
||
try { window.MapOffline && MapOffline.registerProtocol(); } catch (e) { /* byt://-Protokoll für Offline-Tiles */ }
|
||
});
|
||
return _maplibreUIPromise;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// TRACK-VORSCHAU-SNAPSHOT — ein Offscreen-GL-Kontext rendert PNGs (Basemap+Route)
|
||
// ----------------------------------------------------------
|
||
let _snapMap = null, _snapReady = null, _snapChain = Promise.resolve(), _snapReleaseTimer = null;
|
||
const _snapCache = new Map(); // key → data-URL
|
||
const _EMPTY_FC = { type: 'FeatureCollection', features: [] };
|
||
|
||
function _ensureSnapMap() {
|
||
if (_snapReady) return _snapReady;
|
||
_snapReady = loadMapLibreUI().then(() => new Promise((resolve, reject) => {
|
||
const el = document.createElement('div');
|
||
// Aspekt wie .rk-card-preview (360×140); MapLibre rendert in devicePixelRatio → scharf.
|
||
el.style.cssText = 'position:fixed;left:-10000px;top:0;width:360px;height:140px;pointer-events:none;visibility:hidden;';
|
||
document.body.appendChild(el);
|
||
const isDark = document.documentElement.dataset.theme === 'dark';
|
||
const m = new maplibregl.Map({
|
||
container: el, style: MapGLStyle.build({ dark: isDark }),
|
||
center: [10.4515, 51.1657], zoom: 6,
|
||
interactive: false, attributionControl: false,
|
||
preserveDrawingBuffer: true, fadeDuration: 0,
|
||
});
|
||
m.on('error', () => {}); // einzelne Tile-Fehler nicht eskalieren
|
||
m.once('load', () => {
|
||
m.addSource('snap-line', { type: 'geojson', data: _EMPTY_FC });
|
||
m.addSource('snap-pts', { type: 'geojson', data: _EMPTY_FC });
|
||
m.addLayer({ id: 'snap-line', type: 'line', source: 'snap-line',
|
||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||
paint: { 'line-color': '#C4843A', 'line-width': 4, 'line-opacity': 0.95 } });
|
||
m.addLayer({ id: 'snap-pts', type: 'circle', source: 'snap-pts',
|
||
paint: { 'circle-radius': 6, 'circle-color': ['get', 'color'],
|
||
'circle-stroke-color': '#fff', 'circle-stroke-width': 2 } });
|
||
_snapMap = m;
|
||
resolve(m);
|
||
});
|
||
setTimeout(() => { if (!_snapMap) reject(new Error('snap-map load timeout')); }, 8000);
|
||
}));
|
||
return _snapReady;
|
||
}
|
||
|
||
function _renderSnap(track, key) {
|
||
return _ensureSnapMap().then(m => new Promise(resolve => {
|
||
const line = track.map(p => [p.lon, p.lat]);
|
||
m.getSource('snap-line').setData({ type: 'Feature', properties: {},
|
||
geometry: { type: 'LineString', coordinates: line } });
|
||
const a = track[0], b = track[track.length - 1];
|
||
m.getSource('snap-pts').setData({ type: 'FeatureCollection', features: [
|
||
{ type: 'Feature', properties: { color: '#22C55E' }, geometry: { type: 'Point', coordinates: [a.lon, a.lat] } },
|
||
{ type: 'Feature', properties: { color: '#EF4444' }, geometry: { type: 'Point', coordinates: [b.lon, b.lat] } },
|
||
] });
|
||
const bounds = line.reduce((bb, c) => bb.extend(c), new maplibregl.LngLatBounds(line[0], line[0]));
|
||
try { m.fitBounds(bounds, { padding: 22, duration: 0, maxZoom: 16 }); } catch (e) {}
|
||
let done = false;
|
||
const finish = () => {
|
||
if (done) return; done = true;
|
||
m.off('idle', finish);
|
||
requestAnimationFrame(() => {
|
||
let url = null;
|
||
try { url = m.getCanvas().toDataURL('image/png'); } catch (e) {}
|
||
if (url) _snapCache.set(key, url);
|
||
resolve(url);
|
||
});
|
||
};
|
||
m.on('idle', finish);
|
||
setTimeout(finish, 4000); // Fallback falls Tiles hängen
|
||
}));
|
||
}
|
||
|
||
// Offscreen-GL-Kontext nach Leerlauf freigeben — nicht dauerhaft halten, sonst belegt
|
||
// er einen der knappen iOS-WebGL-Kontexte und beschleunigt das Limit (Detailkarten
|
||
// fielen dann auf Leaflet+OSM-Raster zurück). Der PNG-Cache bleibt → kein Neu-Rendern.
|
||
function _releaseSnapMap() {
|
||
_snapReleaseTimer = null;
|
||
if (_snapMap) { try { _snapMap.remove(); } catch (e) {} _snapMap = null; }
|
||
_snapReady = null;
|
||
}
|
||
|
||
function _glSnapshot(track, opts = {}) {
|
||
if (!_uiUseGL()) return Promise.resolve(null); // GL aus → SVG-Fallback beim Aufrufer
|
||
if (!track || track.length < 2) return Promise.resolve(null);
|
||
const key = opts.key || ('t' + track.length + ',' + track[0].lat + ',' + track[0].lon + ',' +
|
||
track[track.length - 1].lat + ',' + track[track.length - 1].lon);
|
||
if (_snapCache.has(key)) return Promise.resolve(_snapCache.get(key));
|
||
if (_snapReleaseTimer) { clearTimeout(_snapReleaseTimer); _snapReleaseTimer = null; }
|
||
// Serielle Verarbeitung am gemeinsamen Offscreen-Kontext.
|
||
const run = _snapChain.then(() => _renderSnap(track, key)).catch(() => null);
|
||
_snapChain = run.catch(() => {});
|
||
run.then(() => {
|
||
if (_snapReleaseTimer) clearTimeout(_snapReleaseTimer);
|
||
_snapReleaseTimer = setTimeout(_releaseSnapMap, 15000);
|
||
});
|
||
return run;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// 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 = `<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>`;
|
||
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 = '<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`,
|
||
geoInput: `${p}-geo-input`,
|
||
geoClear: `${p}-geo-clear`,
|
||
geoResults: `${p}-geo-results`,
|
||
};
|
||
|
||
// HTML in den Container rendern
|
||
function _render(container) {
|
||
container.innerHTML = `
|
||
<div style="position:relative">
|
||
<!-- Geocoding-Suchfeld als Overlay oben — left:46px lässt Zoom-Control frei -->
|
||
<div style="position:absolute;top:8px;left:46px;right:8px;z-index:1001">
|
||
<div style="display:flex;align-items:center;gap:7px;background:rgba(255,255,255,0.96);
|
||
border-radius:var(--radius-full);padding:6px 11px;
|
||
box-shadow:0 2px 8px rgba(0,0,0,0.22);backdrop-filter:blur(4px)">
|
||
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px;flex-shrink:0;color:#aaa"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||
<input type="search" id="${ids.geoInput}" placeholder="Ort oder Adresse suchen…"
|
||
autocomplete="off" autocorrect="off" spellcheck="false"
|
||
style="flex:1;border:none;outline:none;font-size:13px;background:transparent;
|
||
font-family:inherit;color:var(--c-text);min-width:0">
|
||
<button type="button" id="${ids.geoClear}" aria-label="Suche löschen"
|
||
style="display:none;background:none;border:none;padding:2px;cursor:pointer;
|
||
color:#bbb;line-height:1">
|
||
<svg class="ph-icon" aria-hidden="true" style="width:13px;height:13px"><use href="/icons/phosphor.svg#x"></use></svg>
|
||
</button>
|
||
</div>
|
||
<div id="${ids.geoResults}" style="display:none;background:rgba(255,255,255,0.98);
|
||
border-radius:10px;box-shadow:0 4px 14px rgba(0,0,0,0.18);
|
||
margin-top:5px;overflow:hidden;max-height:190px;overflow-y:auto"></div>
|
||
</div>
|
||
<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);
|
||
|
||
// 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 = '<div style="padding:9px 13px;font-size:12px;color:var(--c-text-secondary)">Suche…</div>';
|
||
geoResults.style.display = '';
|
||
}
|
||
try {
|
||
const data = await API.get(`/osm/geocode?q=${encodeURIComponent(q)}`);
|
||
if (!geoResults) return;
|
||
if (!data.length) {
|
||
geoResults.innerHTML = '<div style="padding:9px 13px;font-size:12px;color:var(--c-text-secondary)">Keine Ergebnisse</div>';
|
||
return;
|
||
}
|
||
geoResults.innerHTML = data.map((r, i) => `
|
||
<div data-i="${i}" style="padding:9px 13px;cursor:pointer;border-bottom:1px solid rgba(0,0,0,0.05)">
|
||
<div style="font-size:13px;font-weight:600;color:var(--c-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escape(r.name)}</div>
|
||
${r.subtitle ? `<div style="font-size:11px;color:var(--c-text-secondary)">${escape(r.subtitle)}</div>` : ''}
|
||
</div>`).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 = '<div style="padding:9px 13px;font-size:12px;color:var(--c-text-secondary)">Suche nicht verfügbar</div>';
|
||
}
|
||
}, 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 `<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 _renderRatingsList() {
|
||
const items = _ratings.filter(r => r.kommentar && r.kommentar.trim());
|
||
if (!items.length) return '';
|
||
return `
|
||
<div style="margin-top:var(--space-3);display:flex;flex-direction:column;gap:var(--space-2)">
|
||
${items.map(r => `
|
||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3)">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);margin-bottom:2px">
|
||
<span style="font-weight:var(--weight-semibold);font-size:var(--text-sm);color:var(--c-text)">${escape(r.user_name || 'Anonym')}</span>
|
||
<span style="color:#f59e0b;font-size:var(--text-sm);letter-spacing:1px;flex-shrink:0">${'★'.repeat(r.stars)}<span style="color:var(--c-border)">${'★'.repeat(Math.max(0, 5 - r.stars))}</span></span>
|
||
</div>
|
||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.45;white-space:pre-wrap;word-break:break-word">${escape(r.kommentar)}</div>
|
||
</div>
|
||
`).join('')}
|
||
</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() : ''}
|
||
${_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
|
||
? `<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);
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// NOTE-MEDIA-ATTACHER — wiederverwendbarer Medien-Anhang für Notizen.
|
||
// Buttons (Mediathek/Aufnehmen/Datei/Sprachnachricht), Liste anhängiger
|
||
// Dateien, Sprachaufnahme (MediaRecorder) und bereits gespeicherte Medien
|
||
// (mit Löschen). Genutzt von noteModal (ui.js) UND der Notizblock-Seite
|
||
// (pages/notes.js) — eine Quelle statt Duplikat.
|
||
// const m = UI.noteMediaAttacher({ containerId, noteId, existingMedia });
|
||
// …im Submit nach create/update: await m.uploadAll(noteId, onProgress);
|
||
// ----------------------------------------------------------
|
||
function noteMediaAttacher({ containerId, noteId = null, existingMedia = [] } = {}) {
|
||
const container = document.getElementById(containerId);
|
||
const _noop = { uploadAll: async () => ({ uploaded: [], failed: 0 }), hasPending: () => false, destroy: () => {} };
|
||
if (!container) return _noop;
|
||
|
||
const p = containerId.replace(/[^a-z0-9]/gi, '-');
|
||
const ids = {
|
||
bar: `${p}-bar`, pending: `${p}-pending`, existing: `${p}-existing`,
|
||
rec: `${p}-rec`, recTimer: `${p}-rec-timer`, recStop: `${p}-rec-stop`,
|
||
recCancel: `${p}-rec-cancel`, mic: `${p}-mic`,
|
||
};
|
||
|
||
let _noteId = noteId;
|
||
const _pending = []; // [{ file, url }]
|
||
let _existing = (existingMedia || []).slice();
|
||
const RECORDER_OK = !!(window.MediaRecorder && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||
let _stream = null, _rec = null, _chunks = [], _tick = null, _recStart = 0, _recMax = null, _recCancelled = false;
|
||
|
||
const BTN = 'display:inline-flex;align-items:center;gap:6px;font-size:var(--text-xs);font-weight:600;' +
|
||
'padding:7px 12px;border-radius:var(--radius-full);border:1.5px solid var(--c-border);' +
|
||
'background:var(--c-surface-2);color:var(--c-text-secondary);cursor:pointer';
|
||
const DEL = 'flex-shrink:0;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.08);' +
|
||
'color:var(--c-text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:14px;line-height:1';
|
||
const ROW = 'display:flex;align-items:center;gap:10px;padding:6px;border:1px solid var(--c-border);border-radius:var(--radius-md)';
|
||
|
||
container.innerHTML = `
|
||
<div id="${ids.existing}"></div>
|
||
<div id="${ids.bar}" style="display:flex;flex-wrap:wrap;gap:8px;margin:6px 0">
|
||
<button type="button" id="${p}-gallery" style="${BTN}">${_svgIcon('paperclip')} Foto / Video / Datei</button>
|
||
${RECORDER_OK ? `<button type="button" id="${ids.mic}" style="${BTN}">${_svgIcon('microphone')} Sprachnachricht</button>` : ''}
|
||
</div>
|
||
<div id="${ids.rec}" style="display:none;align-items:center;gap:10px;margin:6px 0;padding:8px 12px;border-radius:var(--radius-md);background:var(--c-danger-subtle,rgba(220,53,53,.12))">
|
||
<span style="width:10px;height:10px;border-radius:50%;background:var(--c-danger,#dc3535);flex-shrink:0"></span>
|
||
<span id="${ids.recTimer}" style="font-variant-numeric:tabular-nums;font-weight:600;color:var(--c-text)">0:00</span>
|
||
<span style="flex:1"></span>
|
||
<button type="button" id="${ids.recCancel}" style="${BTN}">Verwerfen</button>
|
||
<button type="button" id="${ids.recStop}" style="${BTN};border-color:var(--c-danger,#dc3535);color:var(--c-danger,#dc3535)">${_svgIcon('stop')} Stop</button>
|
||
</div>
|
||
<div id="${ids.pending}" style="display:flex;flex-direction:column;gap:6px;margin-top:6px"></div>
|
||
`;
|
||
|
||
// ---- Datei-Picker (nativer Browser-Dialog) ----
|
||
function _openPicker(opts = {}) {
|
||
const tmp = document.createElement('input');
|
||
tmp.type = 'file';
|
||
tmp.multiple = true;
|
||
tmp.accept = 'image/*,video/*';
|
||
tmp.style.display = 'none';
|
||
if (opts.capture) tmp.setAttribute('capture', opts.capture);
|
||
if (opts.noAccept) tmp.removeAttribute('accept');
|
||
tmp.addEventListener('change', () => { if (tmp.files.length) _addFiles(tmp.files); tmp.remove(); });
|
||
document.body.appendChild(tmp);
|
||
tmp.click();
|
||
}
|
||
|
||
function _addFiles(list) {
|
||
for (const f of list) _pending.push({ file: f, url: URL.createObjectURL(f) });
|
||
_renderPending();
|
||
}
|
||
|
||
function _renderPending() {
|
||
const grid = document.getElementById(ids.pending);
|
||
if (!grid) return;
|
||
grid.innerHTML = _pending.map((it, i) => {
|
||
const f = it.file, t = f.type || '';
|
||
let preview, label = '';
|
||
if (t.startsWith('image/')) {
|
||
preview = `<img src="${it.url}" alt="" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0">`;
|
||
label = `<span style="flex:1;min-width:0;font-size:var(--text-xs);color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape(f.name || 'Bild')}</span>`;
|
||
} else if (t.startsWith('video/')) {
|
||
preview = `<video src="${it.url}" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0" muted playsinline></video>`;
|
||
label = `<span style="flex:1;min-width:0;font-size:var(--text-xs);color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape(f.name || 'Video')}</span>`;
|
||
} else if (t.startsWith('audio/')) {
|
||
preview = `<audio controls src="${it.url}" style="height:36px;flex:1;min-width:0"></audio>`;
|
||
} else {
|
||
preview = `<svg class="ph-icon" style="width:30px;height:30px;color:var(--c-text-secondary);flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>`;
|
||
label = `<span style="flex:1;min-width:0;font-size:var(--text-xs);color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape(f.name || 'Datei')}</span>`;
|
||
}
|
||
return `<div style="${ROW}" data-pidx="${i}">${preview}${label}<button type="button" data-pidx="${i}" aria-label="Entfernen" style="${DEL}">✕</button></div>`;
|
||
}).join('');
|
||
grid.querySelectorAll('button[data-pidx]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const i = parseInt(btn.dataset.pidx, 10);
|
||
if (_pending[i]) { try { URL.revokeObjectURL(_pending[i].url); } catch (_) {} _pending.splice(i, 1); }
|
||
_renderPending();
|
||
});
|
||
});
|
||
}
|
||
|
||
function _previewOf(url) {
|
||
if (!url) return url;
|
||
if (/\.(mp4|webm|mov|avi|m4a|aac|mp3|ogg|oga|wav|pdf)$/i.test(url)) return url;
|
||
const dot = url.lastIndexOf('.');
|
||
return dot > 0 ? url.slice(0, dot) + '_preview.webp' : url;
|
||
}
|
||
|
||
function _renderExisting() {
|
||
const wrap = document.getElementById(ids.existing);
|
||
if (!wrap) return;
|
||
if (!_existing.length) { wrap.innerHTML = ''; return; }
|
||
wrap.innerHTML = `<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:4px">${_existing.map(m => {
|
||
let preview, label = '';
|
||
if (m.media_type === 'image') {
|
||
preview = `<img src="${_previewOf(m.url)}" data-full="${m.url}" alt="" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0;cursor:pointer" data-full-open="${m.url}">`;
|
||
} else if (m.media_type === 'video') {
|
||
preview = `<video src="${m.url}" controls playsinline style="height:48px;border-radius:6px;flex:1;min-width:0"></video>`;
|
||
} else if (m.media_type === 'audio') {
|
||
preview = `<audio controls src="${m.url}" style="height:36px;flex:1;min-width:0"></audio>`;
|
||
} else {
|
||
preview = `<a href="${m.url}" target="_blank" rel="noopener" style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;color:var(--c-text-secondary);text-decoration:none"><svg class="ph-icon" style="width:28px;height:28px;flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg><span style="font-size:var(--text-xs);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape((m.media_type === 'pdf' ? 'PDF' : 'Datei'))}</span></a>`;
|
||
}
|
||
return `<div style="${ROW}" data-mid="${m.id}">${preview}${label}<button type="button" data-mid="${m.id}" aria-label="Löschen" style="${DEL}">✕</button></div>`;
|
||
}).join('')}</div>`;
|
||
// CSP-konformer Preview→Original-Fallback
|
||
wrap.querySelectorAll('img[data-full]').forEach(img => {
|
||
img.addEventListener('error', () => { if (img.src !== img.dataset.full) img.src = img.dataset.full; }, { once: true });
|
||
});
|
||
wrap.querySelectorAll('img[data-full-open]').forEach(img => {
|
||
img.addEventListener('click', () => {
|
||
const imgs = _existing.filter(m => m.media_type === 'image').map(m => ({ url: m.url, type: 'image' }));
|
||
const idx = imgs.findIndex(it => it.url === img.dataset.fullOpen);
|
||
if (UI.lightbox) UI.lightbox.show(imgs, Math.max(0, idx));
|
||
});
|
||
});
|
||
wrap.querySelectorAll('button[data-mid]').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const mid = parseInt(btn.dataset.mid, 10);
|
||
if (_noteId == null) { _existing = _existing.filter(m => m.id !== mid); _renderExisting(); return; }
|
||
btn.disabled = true;
|
||
try {
|
||
await API.notes.deleteMedia(_noteId, mid);
|
||
} catch (e) {
|
||
if (e?.status !== 404) { btn.disabled = false; toast.error(e.message || 'Löschen fehlgeschlagen.'); return; }
|
||
}
|
||
_existing = _existing.filter(m => m.id !== mid);
|
||
_renderExisting();
|
||
toast.success('Medium entfernt.');
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---- Sprachaufnahme ----
|
||
function _stopTracks() { if (_stream) { _stream.getTracks().forEach(t => t.stop()); _stream = null; } }
|
||
|
||
function _setRecState(on) {
|
||
const rec = document.getElementById(ids.rec);
|
||
const bar = document.getElementById(ids.bar);
|
||
if (rec) rec.style.display = on ? 'flex' : 'none';
|
||
if (bar) { bar.style.opacity = on ? '.4' : ''; bar.querySelectorAll('button').forEach(b => b.disabled = on); }
|
||
}
|
||
|
||
function _updateTimer() {
|
||
const el = document.getElementById(ids.recTimer);
|
||
if (!el) return;
|
||
const s = Math.floor((Date.now() - _recStart) / 1000);
|
||
el.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||
}
|
||
|
||
async function _startRecording() {
|
||
try {
|
||
_stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
} catch (_) {
|
||
toast.error('Kein Mikrofon-Zugriff. Bitte in den Geräte-Einstellungen erlauben.');
|
||
return;
|
||
}
|
||
const cands = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'];
|
||
const mime = cands.find(t => { try { return MediaRecorder.isTypeSupported(t); } catch (_) { return false; } }) || '';
|
||
try { _rec = mime ? new MediaRecorder(_stream, { mimeType: mime }) : new MediaRecorder(_stream); }
|
||
catch (_) { _rec = new MediaRecorder(_stream); }
|
||
_chunks = [];
|
||
_recCancelled = false;
|
||
_rec.addEventListener('dataavailable', e => { if (e.data && e.data.size) _chunks.push(e.data); });
|
||
_rec.addEventListener('stop', () => {
|
||
_stopTracks();
|
||
if (_tick) { clearInterval(_tick); _tick = null; }
|
||
if (_recMax) { clearTimeout(_recMax); _recMax = null; }
|
||
_setRecState(false);
|
||
if (_recCancelled) { _chunks = []; return; }
|
||
const type = _rec.mimeType || mime || 'audio/webm';
|
||
const ext = type.includes('mp4') ? '.m4a' : type.includes('ogg') ? '.ogg' : '.webm';
|
||
const blob = new Blob(_chunks, { type });
|
||
_chunks = [];
|
||
if (blob.size > 0) _addFiles([new File([blob], `sprachnachricht${ext}`, { type })]);
|
||
});
|
||
_rec.start();
|
||
_recStart = Date.now();
|
||
_setRecState(true);
|
||
_updateTimer();
|
||
_tick = setInterval(_updateTimer, 250);
|
||
_recMax = setTimeout(() => { if (_rec && _rec.state === 'recording') { _recCancelled = false; try { _rec.stop(); } catch (_) {} } }, 5 * 60 * 1000);
|
||
}
|
||
|
||
function _stopRecording(cancel) {
|
||
_recCancelled = !!cancel;
|
||
if (_rec && _rec.state !== 'inactive') { try { _rec.stop(); } catch (_) {} }
|
||
else { _stopTracks(); _setRecState(false); }
|
||
}
|
||
|
||
// ---- Event-Bindung ----
|
||
// Ein Button ohne accept: iOS zeigt im Aktionsblatt von sich aus Mediathek,
|
||
// Kamera UND Datei-Auswahl — separate Buttons dafür sind überflüssig.
|
||
document.getElementById(`${p}-gallery`)?.addEventListener('click', () => _openPicker({ noAccept: true }));
|
||
if (RECORDER_OK) document.getElementById(ids.mic)?.addEventListener('click', _startRecording);
|
||
document.getElementById(ids.recStop)?.addEventListener('click', () => _stopRecording(false));
|
||
document.getElementById(ids.recCancel)?.addEventListener('click', () => _stopRecording(true));
|
||
|
||
_renderExisting();
|
||
_renderPending();
|
||
|
||
// ---- Öffentliche API ----
|
||
async function uploadAll(noteIdArg, onProgress) {
|
||
if (noteIdArg != null) _noteId = noteIdArg;
|
||
if (!_pending.length) return { uploaded: [], failed: 0 };
|
||
const total = _pending.length;
|
||
let done = 0;
|
||
onProgress?.(0, total);
|
||
const results = await Promise.all(_pending.map(async (it) => {
|
||
try {
|
||
const toUpload = await API.compressImage(it.file); // nur Bilder werden komprimiert
|
||
const fd = new FormData();
|
||
fd.append('file', toUpload);
|
||
const m = await API.notes.uploadMedia(_noteId, fd);
|
||
onProgress?.(++done, total);
|
||
return { ok: true, m };
|
||
} catch (_) {
|
||
onProgress?.(++done, total);
|
||
return { ok: false };
|
||
}
|
||
}));
|
||
const uploaded = results.filter(r => r.ok).map(r => r.m);
|
||
const failed = results.filter(r => !r.ok).length;
|
||
if (failed) toast.warning(`${failed} Medi${failed > 1 ? 'en' : 'um'} konnte${failed > 1 ? 'n' : ''} nicht hochgeladen werden.`);
|
||
_pending.forEach(it => { try { URL.revokeObjectURL(it.url); } catch (_) {} });
|
||
_pending.length = 0;
|
||
_renderPending();
|
||
_existing = _existing.concat(uploaded);
|
||
return { uploaded, failed };
|
||
}
|
||
|
||
function destroy() {
|
||
_stopRecording(true);
|
||
_stopTracks();
|
||
if (_tick) { clearInterval(_tick); _tick = null; }
|
||
if (_recMax) { clearTimeout(_recMax); _recMax = null; }
|
||
_pending.forEach(it => { try { URL.revokeObjectURL(it.url); } catch (_) {} });
|
||
_pending.length = 0;
|
||
}
|
||
|
||
return {
|
||
uploadAll,
|
||
hasPending: () => _pending.length > 0 || (_rec && _rec.state === 'recording'),
|
||
destroy,
|
||
};
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// 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 = `
|
||
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
|
||
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
|
||
padding-bottom:env(safe-area-inset-bottom,0px)">
|
||
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
|
||
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
|
||
<div>
|
||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)">${_svgIcon('note-pencil')} Notiz</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${escape(parentLabel)}</div>
|
||
</div>
|
||
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
|
||
</div>
|
||
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
|
||
<form id="by-note-form">
|
||
<textarea id="by-note-text" class="form-control" rows="5"
|
||
placeholder="Notiz eingeben…"
|
||
style="width:100%;resize:vertical"></textarea>
|
||
</form>
|
||
<div id="by-note-media" style="margin-top:var(--space-3)"></div>
|
||
</div>
|
||
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
|
||
display:flex;gap:var(--space-2);flex-shrink:0">
|
||
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
|
||
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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;
|
||
let existingNote = null;
|
||
try {
|
||
const res = await API.notes.get(parentType, parentId);
|
||
// GET /notes/{type}/{id} liefert ein Array (neueste zuerst) — die jüngste
|
||
// Notiz bearbeiten statt bei jedem Öffnen eine neue anzulegen (Duplikate).
|
||
existingNote = Array.isArray(res) ? res[0] : res;
|
||
if (existingNote?.id) {
|
||
existingNoteId = existingNote.id;
|
||
textarea.value = existingNote.text || '';
|
||
}
|
||
} catch (_) { /* keine Notiz vorhanden — ok */ }
|
||
|
||
const _media = noteMediaAttacher({
|
||
containerId: 'by-note-media',
|
||
noteId: existingNoteId,
|
||
existingMedia: existingNote?.media_items || [],
|
||
});
|
||
|
||
setTimeout(() => textarea.focus(), 100);
|
||
|
||
const _close = () => { _media.destroy(); 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();
|
||
// Reine Medien-Notiz (nur Foto/Sprachnachricht) ist erlaubt — nur ganz
|
||
// leer (kein Text, keine Medien) verhindern.
|
||
if (!text && !_media.hasPending() && !existingNoteId) {
|
||
toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.');
|
||
return;
|
||
}
|
||
setLoading(saveBtn, true);
|
||
try {
|
||
const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() };
|
||
let noteId = existingNoteId;
|
||
if (noteId) {
|
||
await API.notes.update(noteId, payload);
|
||
} else {
|
||
const created = await API.notes.create(parentType, parentId, payload);
|
||
noteId = created?.id;
|
||
}
|
||
if (noteId && _media.hasPending()) {
|
||
await _media.uploadAll(noteId, (d, t) => { saveBtn.textContent = `${d}/${t} hochgeladen…`; });
|
||
}
|
||
toast.success('Notiz gespeichert.');
|
||
_close();
|
||
} catch (err) {
|
||
toast.error(err.message || 'Fehler beim Speichern.');
|
||
setLoading(saveBtn, false);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// LIGHTBOX — Vollbild-Viewer für Bilder/Videos mit Vor/Zurück.
|
||
// items: [{ url, type }] (type 'image'|'video', Default 'image') oder
|
||
// reine URL-Strings. Global, damit Notizen, Tagebuch & Co. EINEN Viewer
|
||
// teilen (app.js erwartet UI.lightbox.show bereits).
|
||
// ----------------------------------------------------------
|
||
const lightbox = (() => {
|
||
function show(items, startIdx = 0) {
|
||
const list = (Array.isArray(items) ? items : [items])
|
||
.map(it => (typeof it === 'string' ? { url: it } : it))
|
||
.filter(it => it && it.url);
|
||
if (!list.length) return;
|
||
let idx = Math.min(Math.max(0, startIdx | 0), list.length - 1);
|
||
|
||
const lb = document.createElement('div');
|
||
lb.id = 'by-lightbox';
|
||
lb.style.cssText = 'position:fixed;inset:0;z-index:3000;background:#000;display:flex;flex-direction:column';
|
||
|
||
const render = () => {
|
||
const m = list[idx];
|
||
const media = (m.type === 'video')
|
||
? `<video src="${escape(m.url)}" controls autoplay playsinline style="max-width:100%;max-height:100%;display:block"></video>`
|
||
: `<img src="${escape(m.url)}" alt="" style="max-width:100%;max-height:100%;object-fit:contain;display:block">`;
|
||
lb.innerHTML = `
|
||
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">${media}</div>
|
||
<div style="display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding-top:10px;
|
||
padding-bottom:calc(env(safe-area-inset-bottom,0px) + 12px);
|
||
padding-left:calc(env(safe-area-inset-left,0px) + 16px);
|
||
padding-right:calc(env(safe-area-inset-right,0px) + 16px);
|
||
flex-shrink:0;background:rgba(0,0,0,.5);gap:8px">
|
||
<button id="by-lb-close" style="background:rgba(255,255,255,.15);border:none;border-radius:24px;height:48px;
|
||
color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;padding:0 16px;
|
||
font-size:15px;font-weight:500">${_svgIcon('arrow-left')} Schließen</button>
|
||
<span style="color:rgba(255,255,255,.7);font-size:14px;text-align:center;white-space:nowrap">${list.length > 1 ? `${idx + 1} / ${list.length}` : ''}</span>
|
||
${list.length > 1 ? `
|
||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||
<button id="by-lb-prev" style="background:rgba(255,255,255,.15);border:none;border-radius:50%;width:48px;height:48px;
|
||
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center${idx === 0 ? ';opacity:.3;pointer-events:none' : ''}">‹</button>
|
||
<button id="by-lb-next" style="background:rgba(255,255,255,.15);border:none;border-radius:50%;width:48px;height:48px;
|
||
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center${idx === list.length - 1 ? ';opacity:.3;pointer-events:none' : ''}">›</button>
|
||
</div>` : '<div></div>'}
|
||
</div>`;
|
||
lb.querySelector('#by-lb-close').addEventListener('click', () => lb.remove());
|
||
lb.querySelector('#by-lb-prev')?.addEventListener('click', () => { if (idx > 0) { idx--; render(); } });
|
||
lb.querySelector('#by-lb-next')?.addEventListener('click', () => { if (idx < list.length - 1) { idx++; render(); } });
|
||
};
|
||
render();
|
||
document.body.appendChild(lb);
|
||
}
|
||
return { show };
|
||
})();
|
||
|
||
// Öffentliche API
|
||
return {
|
||
toast, modal,
|
||
noteModal, noteMediaAttacher, lightbox,
|
||
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,
|
||
loadMapLibreUI,
|
||
leafletMarker,
|
||
locationPicker,
|
||
map,
|
||
ratingStars,
|
||
dogChip,
|
||
bindDogChip,
|
||
};
|
||
|
||
})();
|