banyaro/backend/static/js/ui.js
rene e86d89f3d9 Notiz-Medien & Sprachnachrichten: Fotos/Videos/Dateien + Audio an Notizen
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).
2026-06-14 20:22:35 +02:00

2151 lines
96 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/* ============================================================
BAN YARO — UI Helpers
Alle UI-Interaktionen an einem Ort.
Toast, Modal, Loading, Confirm — einmal gebaut, überall nutzbar.
============================================================ */
const UI = (() => {
// ----------------------------------------------------------
// PHOSPHOR ICON HELPER — erzeugt SVG-String für Templates
// ----------------------------------------------------------
function _svgIcon(name, extraClass = '') {
return `<svg class="ph-icon${extraClass ? ' ' + extraClass : ''}" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
}
// ----------------------------------------------------------
// TOAST
// ----------------------------------------------------------
const toast = (() => {
const container = () => document.getElementById('toast-container');
function show(message, type = 'default', duration = 3500) {
const el = document.createElement('div');
el.className = `toast${type !== 'default' ? ` toast-${type}` : ''}`;
const iconName = { success: 'check', danger: 'x', warning: 'warning', info: 'info' }[type];
el.innerHTML = iconName
? `${_svgIcon(iconName)}<span>${message}</span>`
: `<span>${message}</span>`;
container().appendChild(el);
const timer = setTimeout(() => remove(el), duration);
el.addEventListener('click', () => { clearTimeout(timer); remove(el); });
}
function remove(el) {
el.classList.add('removing');
const fallback = setTimeout(() => el.remove(), 300);
el.addEventListener('animationend', () => { clearTimeout(fallback); el.remove(); }, { once: true });
}
// callable as UI.toast(msg, type) and UI.toast.success(msg) etc.
function t(msg, type = 'default', dur) { show(msg, type, dur); }
t.show = show;
t.success = (msg, dur) => show(msg, 'success', dur);
t.error = (msg, dur) => show(msg, 'danger', dur || 5000);
t.warning = (msg, dur) => show(msg, 'warning', dur);
t.info = (msg, dur) => show(msg, 'info', dur);
return t;
})();
// ----------------------------------------------------------
// MODAL
// ----------------------------------------------------------
const modal = (() => {
let _current = null;
function open({ title, body, footer, onClose, size } = {}) {
close(); // vorheriges schließen
const overlay = document.createElement('div');
overlay.className = 'modal-overlay' + (size ? ` modal-overlay--${size}` : '');
overlay.innerHTML = `
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-handle"></div>
${title ? `
<div class="modal-header">
<span class="modal-title">${title}</span>
<button class="btn btn-ghost btn-icon modal-close-btn" aria-label="Schließen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg></button>
</div>
` : ''}
<div class="modal-body">${body || ''}</div>
${footer ? `<div class="modal-footer">${footer}</div>` : ''}
</div>
`;
overlay.querySelector('.modal-close-btn')?.addEventListener('click', close);
overlay.addEventListener('click', e => {
if (e.target.closest('[data-modal-close]')) close();
});
document.getElementById('modal-container').appendChild(overlay);
document.documentElement.classList.add('modal-open');
// Tastatur auf Mobilgeräten: Modal-Höhe begrenzen + fokussiertes Feld scrollen
let _vvCleanup = null;
const vv = window.visualViewport;
const modal = overlay.querySelector('.modal');
if (vv) {
const adjust = () => {
const visible = vv.height;
const offset = vv.offsetTop;
const kb = Math.max(0, window.innerHeight - visible - offset);
// Overlay-Padding damit Modal nach oben rückt
overlay.style.paddingBottom = (kb + 8) + 'px';
// Modal-Höhe hart begrenzen damit modal-body scrollbar bleibt
if (modal) modal.style.maxHeight = (visible - 24) + 'px';
};
vv.addEventListener('resize', adjust);
vv.addEventListener('scroll', adjust);
_vvCleanup = () => {
vv.removeEventListener('resize', adjust);
vv.removeEventListener('scroll', adjust);
overlay.style.paddingBottom = '';
if (modal) modal.style.maxHeight = '';
};
}
// Fokussiertes Feld innerhalb modal-body scrollen (iOS scrollIntoView
// arbeitet nicht zuverlässig in overflow-Containern)
const _onFocusin = e => {
const el = e.target;
if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA' && el.tagName !== 'SELECT') return;
setTimeout(() => {
const body = el.closest('.modal-body');
if (!body) { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return; }
const elBottom = el.getBoundingClientRect().bottom;
const vvBottom = vv ? (vv.offsetTop + vv.height) : window.innerHeight;
const gap = elBottom - vvBottom + 56; // 56px Puffer über Tastatur
if (gap > 0) body.scrollTop += gap;
}, 380);
};
overlay.addEventListener('focusin', _onFocusin);
// -----------------------------------------------------
// Accessibility: ESC schließt + Focus-Trap
// -----------------------------------------------------
const FOCUSABLE_SEL = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const _getFocusables = () =>
Array.from(modal?.querySelectorAll(FOCUSABLE_SEL) || [])
.filter(el => el.offsetParent !== null || el === document.activeElement);
const _prevFocus = document.activeElement;
const _onKeydown = e => {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key !== 'Tab') return;
const focusables = _getFocusables();
if (!focusables.length) { e.preventDefault(); return; }
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
if (document.activeElement === first || !modal.contains(document.activeElement)) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last || !modal.contains(document.activeElement)) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', _onKeydown);
// Erstes fokussierbares Element autofokussieren (nach Render)
setTimeout(() => {
const focusables = _getFocusables();
// Schließen-Button überspringen, falls weitere Elemente vorhanden
const target = focusables.find(el => !el.classList.contains('modal-close-btn')) || focusables[0];
target?.focus();
}, 50);
_current = { overlay, onClose, _vvCleanup, _onFocusin, _onKeydown, _prevFocus };
return overlay.querySelector('.modal');
}
function close() {
if (!_current) return;
const { onClose, _vvCleanup, _onFocusin, _onKeydown, _prevFocus } = _current;
onClose?.();
_vvCleanup?.();
if (_onFocusin) _current.overlay.removeEventListener('focusin', _onFocusin);
if (_onKeydown) document.removeEventListener('keydown', _onKeydown);
_current.overlay.remove();
document.documentElement.classList.remove('modal-open');
_current = null;
// Fokus auf vorheriges Element zurücksetzen (falls noch im DOM)
if (_prevFocus && typeof _prevFocus.focus === 'function' && document.body.contains(_prevFocus)) {
try { _prevFocus.focus(); } catch (_) {}
}
// iOS Safari setzt den Zoom nach Input-Fokus nicht zurück — Viewport kurz neu setzen
const meta = document.querySelector('meta[name="viewport"]');
if (meta) {
const orig = meta.content;
meta.content = orig + ',maximum-scale=1';
requestAnimationFrame(() => { meta.content = orig; });
}
}
// Bestätigungsdialog
function confirm({ title, message, confirmText = 'OK', cancelText = 'Abbrechen',
danger = false } = {}) {
return new Promise(resolve => {
const m = open({
title,
body: `<p style="color:var(--c-text-secondary)">${message}</p>`,
footer: `
<button class="btn btn-secondary" id="modal-cancel">${cancelText}</button>
<button class="btn ${danger ? 'btn-danger' : 'btn-primary'}" id="modal-confirm">
${confirmText}
</button>
`,
onClose: () => resolve(false),
});
m.parentElement.querySelector('#modal-cancel').addEventListener('click', () => {
resolve(false); close();
});
m.parentElement.querySelector('#modal-confirm').addEventListener('click', () => {
resolve(true); close();
});
});
}
return { open, close, confirm };
})();
// ----------------------------------------------------------
// LOADING STATE für Buttons
// ----------------------------------------------------------
function setLoading(btn, loading) {
if (loading) {
btn._originalContent = btn.innerHTML;
btn.innerHTML = '<span class="spinner spinner-sm"></span>';
btn.disabled = true;
} else {
btn.innerHTML = btn._originalContent || btn.innerHTML;
btn.disabled = false;
}
}
// ----------------------------------------------------------
// ASYNC BUTTON: Button-Click → Loader → Ergebnis → Toast
// Verwendung: UI.asyncButton(btn, async () => { await API.something() })
// ----------------------------------------------------------
async function asyncButton(btn, fn, { successMsg, errorMsg } = {}) {
setLoading(btn, true);
try {
const result = await fn();
if (successMsg) toast.success(successMsg);
return result;
} catch (err) {
const msg = errorMsg || err.message || 'Ein Fehler ist aufgetreten.';
toast.error(msg);
throw err;
} finally {
setLoading(btn, false);
}
}
// ----------------------------------------------------------
// FORMULAR-HELPER
// ----------------------------------------------------------
function formData(form) {
const data = {};
new FormData(form).forEach((v, k) => { data[k] = v; });
return data;
}
function setFormError(form, fieldName, message) {
const field = form.querySelector(`[name="${fieldName}"]`);
if (!field) return;
field.classList.add('is-invalid');
let hint = field.parentElement.querySelector('.form-error');
if (!hint) {
hint = document.createElement('span');
hint.className = 'form-error';
field.parentElement.appendChild(hint);
}
hint.textContent = message;
}
function clearFormErrors(form) {
form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
form.querySelectorAll('.form-error').forEach(el => el.remove());
}
// ----------------------------------------------------------
// LEERER ZUSTAND (Empty State)
// ----------------------------------------------------------
// ----------------------------------------------------------
// PREVIEW-URL — für lokale Media: _preview.webp statt Original
// ----------------------------------------------------------
// Verwendung:
// <img src="${UI.previewUrl(profile.foto_url)}"
// onerror="${UI.previewFallback(profile.foto_url)}">
function previewUrl(url) {
if (!url || !url.startsWith('/media/')) return url || '';
return url.replace(/\.(jpe?g|png|gif|webp)$/i, '_preview.webp');
}
// onerror-Handler: bei 404 vom _preview die Original-URL nachladen,
// bei zweitem Fehler das Image komplett ausblenden (kein Browser-Fragezeichen).
// Setzt eine Klasse 'img-broken' damit Container ggf. einen Fallback zeigen können.
function previewFallback(originalUrl) {
if (!originalUrl || !originalUrl.startsWith('/media/')) {
return `this.style.display='none';this.classList.add('img-broken')`;
}
const safe = String(originalUrl).replace(/'/g, "\\'");
return `if(this.src.includes('_preview')&&!this.dataset.fb){this.dataset.fb='1';this.src='${safe}'}else{this.style.display='none';this.classList.add('img-broken')}`;
}
function emptyState({ icon, title, text, action } = {}) {
return `
<div class="empty-state">
${icon ? `<div class="empty-state-icon">${icon}</div>` : ''}
${title ? `<div class="empty-state-title">${title}</div>` : ''}
${text ? `<div class="empty-state-text">${text}</div>` : ''}
${action ? `<div class="mt-4">${action}</div>` : ''}
</div>
`;
}
// ----------------------------------------------------------
// ERROR-STATE (dedizierte Error-UI, optional mit Retry-Button)
// ----------------------------------------------------------
// Verwendung:
// container.innerHTML = UI.errorState({
// title: 'Fehler beim Laden',
// message: err.message,
// retry: async () => { await _loadData(); }
// });
let _errorRetryHandlers = new Map();
function errorState({ icon, title = 'Etwas ist schiefgelaufen', message = '', retry = null } = {}) {
const iconHtml = icon || _svgIcon('warning-circle');
const retryId = retry ? `err-retry-${Date.now()}-${Math.random().toString(36).slice(2,7)}` : '';
if (retry) _errorRetryHandlers.set(retryId, retry);
setTimeout(() => {
if (!retryId) return;
const btn = document.getElementById(retryId);
const fn = _errorRetryHandlers.get(retryId);
if (btn && fn) {
btn.addEventListener('click', () => asyncButton(btn, fn));
_errorRetryHandlers.delete(retryId);
}
}, 0);
return `
<div class="empty-state" role="alert">
<div class="empty-state-icon text-danger">${iconHtml}</div>
<div class="empty-state-title">${escHtml(title)}</div>
${message ? `<div class="empty-state-text">${escHtml(message)}</div>` : ''}
${retry ? `<div class="mt-4">
<button id="${retryId}" class="btn btn-primary btn-sm">
${_svgIcon('arrow-clockwise')} Erneut versuchen
</button>
</div>` : ''}
</div>
`;
}
// ----------------------------------------------------------
// SKELETON LIST — Karten-Skeleton für Listen-Loading
// ----------------------------------------------------------
// Verwendung: container.innerHTML = UI.skeletonList(5);
function skeletonList(count = 4) {
return `<div class="flex-col-gap-3">${
Array.from({ length: count }, () => `
<div class="card" style="padding:var(--space-3);display:flex;gap:var(--space-3);align-items:center">
<div style="width:48px;height:48px;border-radius:50%;
background:var(--c-surface-2);animation:skeleton-pulse 1.5s ease infinite;
flex-shrink:0"></div>
<div class="flex-1-min">
<div style="height:14px;width:60%;background:var(--c-surface-2);
border-radius:var(--radius-sm);margin-bottom:var(--space-2);
animation:skeleton-pulse 1.5s ease infinite"></div>
<div style="height:10px;width:85%;background:var(--c-surface-2);
border-radius:var(--radius-sm);
animation:skeleton-pulse 1.5s ease infinite"></div>
</div>
</div>
`).join('')
}</div>
<style>@keyframes skeleton-pulse{0%,100%{opacity:1}50%{opacity:0.4}}</style>`;
}
// ----------------------------------------------------------
// MONEY-INPUT (Euro-Input mit Locale-Format)
// ----------------------------------------------------------
// Verwendung: UI.moneyInput({ name: 'betrag', value: 12.50, required: true })
// Rendert: <div class="money-input-wrap"><span>€</span><input type="number" ...></div>
function moneyInput({ name, value = '', placeholder = '0,00', required = false, currency = '€' } = {}) {
const val = (value === '' || value == null) ? '' : Number(value).toFixed(2).replace('.', ',');
return `
<div style="position:relative;display:flex;align-items:center">
<span style="position:absolute;left:var(--space-3);color:var(--c-text-muted);
pointer-events:none;font-weight:600">${currency}</span>
<input type="text" inputmode="decimal" name="${escHtml(name)}"
class="form-control" placeholder="${escHtml(placeholder)}"
style="padding-left:calc(var(--space-3) + 18px)"
pattern="[0-9]+([,.][0-9]{1,2})?"
value="${val}" ${required ? 'required' : ''}>
</div>
`;
}
// Money parser: Frontend-Helper für Form-Submit
function parseMoney(str) {
if (str == null || str === '') return null;
const cleaned = String(str).replace(',', '.').replace(/[^0-9.]/g, '');
const n = parseFloat(cleaned);
return isNaN(n) ? null : Math.round(n * 100) / 100;
}
// ----------------------------------------------------------
// DATE-PICKER (Wrapper für <input type=date> mit Label)
// ----------------------------------------------------------
// Verwendung: UI.datePicker({ name: 'datum', label: 'Datum', value: '2026-05-27', max: 'today' })
function datePicker({ name, label = '', value = '', min = '', max = '', required = false } = {}) {
const today = new Date().toISOString().slice(0, 10);
const _min = min === 'today' ? today : min;
const _max = max === 'today' ? today : max;
const id = `dp-${name}-${Date.now().toString(36)}`;
return `
${label ? `<label for="${id}" class="label-block">${escHtml(label)}${required ? ' *' : ''}</label>` : ''}
<input type="date" id="${id}" name="${escHtml(name)}" class="form-control"
value="${escHtml(value || '')}"
${_min ? `min="${escHtml(_min)}"` : ''}
${_max ? `max="${escHtml(_max)}"` : ''}
${required ? 'required' : ''}>
`;
}
// ----------------------------------------------------------
// MAP — Leaflet-Karte zentralisiert erstellen
// ----------------------------------------------------------
// Verwendung:
// const map = await UI.map.create('mein-map', { center:[51,10], zoom:6 });
// Optional: { darkFilter: true } für CSS-Filter im Dark-Mode
const map = {
OSM_URL: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
OSM_MAX_ZOOM: 19,
async create(containerId, options = {}) {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Alias für ältere Aufrufe
const escHtml = escape;
// ----------------------------------------------------------
// PAGE INFO — generische Seiten-Hilfe
// config: { pageId, title, icon?, intro, steps?: [{icon,title,text}], tip? }
// Erstes Öffnen: expandierter Banner. Danach: kleines ? im Header.
// ----------------------------------------------------------
function pageInfo(container, config) {
const seenKey = 'help_seen_' + config.pageId;
const seen = !!localStorage.getItem(seenKey);
function _buildSteps() {
if (!config.steps?.length) return '';
return config.steps.map(s => `
<div class="pinfo-step">
${s.icon ? `<span class="pinfo-step-icon">${_svgIcon(s.icon)}</span>` : ''}
<div>
${s.title ? `<div class="pinfo-step-title">${s.title}</div>` : ''}
<div class="pinfo-step-text">${s.text}</div>
</div>
</div>`).join('');
}
function _openModal() {
modal.open({
title: `${_svgIcon(config.icon || 'question')} ${config.title}`,
body: `
<div class="pinfo-modal">
<p class="pinfo-intro">${config.intro}</p>
${config.steps?.length ? `<div class="pinfo-steps">${_buildSteps()}</div>` : ''}
${config.tip ? `<div class="pinfo-tip">${_svgIcon('lightbulb')} ${config.tip}</div>` : ''}
</div>`,
});
}
// Kein automatischer absolut-positionierter Trigger mehr.
// Aufrufer kann openModal() nutzen und den Button selbst platzieren.
// Banner beim ersten Besuch (nicht wenn defaultClosed gesetzt)
if (!seen && !config.defaultClosed) {
localStorage.setItem(seenKey, '1');
const banner = document.createElement('div');
banner.className = 'pinfo-banner';
banner.innerHTML = `
<div class="pinfo-banner-head">
<span class="pinfo-banner-icon">${_svgIcon(config.icon || 'info')}</span>
<span class="pinfo-banner-title">${config.title}</span>
<button class="pinfo-banner-close" aria-label="Schließen">${_svgIcon('x')}</button>
</div>
<div class="pinfo-banner-intro">${config.intro}</div>
${config.steps?.length ? `<div class="pinfo-steps pinfo-steps--compact">${_buildSteps()}</div>` : ''}
<button class="pinfo-banner-more">Mehr erfahren ${_svgIcon('arrow-right')}</button>
`;
banner.querySelector('.pinfo-banner-close').addEventListener('click', () => banner.remove());
banner.querySelector('.pinfo-banner-more').addEventListener('click', () => { banner.remove(); _openModal(); });
container.insertAdjacentElement('afterbegin', banner);
}
// Inline-Trigger-Button (für Aufrufer zum Einbetten)
function makeTriggerBtn() {
const btn = document.createElement('button');
btn.className = 'pinfo-trigger-inline';
btn.setAttribute('aria-label', 'Hilfe');
btn.innerHTML = _svgIcon('question');
btn.addEventListener('click', _openModal);
return btn;
}
return { openModal: _openModal, makeTriggerBtn };
}
// ----------------------------------------------------------
// HELP TOOLTIP — inline ? Badge mit Klick-Tooltip
// ----------------------------------------------------------
function help(text) {
return `<button class="by-help-btn" data-help="${escape(text)}" aria-label="Hilfe" type="button">
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px">
<use href="/icons/phosphor.svg#question"></use>
</svg>
</button>`;
}
// Event-Delegation für Help-Tooltips — einmalig registrieren
document.addEventListener('click', e => {
const btn = e.target.closest('.by-help-btn');
if (!btn) {
document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove());
return;
}
e.stopPropagation();
document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove());
const tip = document.createElement('div');
tip.className = 'by-help-tooltip';
tip.textContent = btn.dataset.help;
document.body.appendChild(tip);
const r = btn.getBoundingClientRect();
tip.style.top = (r.bottom + window.scrollY + 6) + 'px';
tip.style.left = Math.max(8, r.left + window.scrollX - tip.offsetWidth / 2 + r.width / 2) + 'px';
const maxL = window.innerWidth - tip.offsetWidth - 8;
if (parseFloat(tip.style.left) > maxL) tip.style.left = maxL + 'px';
});
// ----------------------------------------------------------
// SAVE TO ALBUM — Foto/Video nach Kamera-Aufnahme in Mediathek
// anbieten. Nur bei capture-Aufnahmen aufrufen (nicht Galerie).
// Nutzt Web Share API wenn verfügbar, sonst <a download> Fallback.
// ----------------------------------------------------------
async function saveToAlbum(file) {
if (!file) return;
// Web Share API mit Datei-Support (iOS Safari 15+, Chrome Android)
if (navigator.canShare && navigator.canShare({ files: [file] })) {
try {
await navigator.share({
files: [file],
title: file.name || 'Foto',
});
} catch (err) {
// AbortError = User hat Sheet geschlossen — kein Fehler anzeigen
if (err?.name !== 'AbortError') {
console.warn('saveToAlbum share error:', err);
}
}
return;
}
// Fallback: direkter Download per <a download>
const url = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = url;
a.download = file.name || ('foto_' + Date.now() + (file.type === 'video/mp4' ? '.mp4' : '.jpg'));
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 2000);
}
// ----------------------------------------------------------
// LEAFLET LAZY LOADER — zentrales Laden von Leaflet + MarkerCluster
// Dedupliziert: mehrere gleichzeitige Aufrufe warten auf dasselbe Promise.
//
// Verwendung:
// await UI.loadLeaflet(); // nur Leaflet
// await UI.loadLeaflet(true); // Leaflet + MarkerCluster
// ----------------------------------------------------------
let _leafletPromise = null;
function loadLeaflet(withCluster = false) {
if (!_leafletPromise) {
_leafletPromise = new Promise((resolve, reject) => {
// CSS (Duplikat-Check)
const cssLoaded = document.querySelector('link[href*="leaflet"]')
? Promise.resolve()
: new Promise(res => {
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = '/css/leaflet.css';
link.onload = res; link.onerror = res;
document.head.appendChild(link);
});
cssLoaded.then(() => {
if (window.L) { resolve(); return; }
if (document.querySelector('script[src*="leaflet.js"]')) {
// Script-Tag schon da — warten bis window.L gesetzt ist
const poll = setInterval(() => {
if (window.L) { clearInterval(poll); resolve(); }
}, 50);
return;
}
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
});
}
if (!withCluster) return _leafletPromise;
// MarkerCluster zusätzlich laden
return _leafletPromise.then(() => {
if (window.L && L.markerClusterGroup) return;
// CSS
if (!document.querySelector('link[href*="MarkerCluster"]')) {
['MarkerCluster.css', 'MarkerCluster.Default.css'].forEach(name => {
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = `/css/${name}`;
document.head.appendChild(link);
});
}
// JS
if (document.querySelector('script[src*="markercluster"]') ||
document.querySelector('script[src*="MarkerCluster"]')) return;
return new Promise(resolve => {
const s = document.createElement('script');
s.src = '/js/leaflet.markercluster.js';
s.onload = resolve;
s.onerror = resolve; // graceful degradation
document.head.appendChild(s);
});
});
}
// ----------------------------------------------------------
// 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,
};
})();