/* ============================================================
BAN YARO — UI Helpers
Alle UI-Interaktionen an einem Ort.
Toast, Modal, Loading, Confirm — einmal gebaut, überall nutzbar.
============================================================ */
const UI = (() => {
// ----------------------------------------------------------
// PHOSPHOR ICON HELPER — erzeugt SVG-String für Templates
// ----------------------------------------------------------
function _svgIcon(name, extraClass = '') {
return ``;
}
// ----------------------------------------------------------
// TOAST
// ----------------------------------------------------------
const toast = (() => {
const container = () => document.getElementById('toast-container');
function show(message, type = 'default', duration = 3500) {
const el = document.createElement('div');
el.className = `toast${type !== 'default' ? ` toast-${type}` : ''}`;
const iconName = { success: 'check', danger: 'x', warning: 'warning', info: 'info' }[type];
el.innerHTML = iconName
? `${_svgIcon(iconName)}${message}`
: `${message}`;
container().appendChild(el);
const timer = setTimeout(() => remove(el), duration);
el.addEventListener('click', () => { clearTimeout(timer); remove(el); });
}
function remove(el) {
el.classList.add('removing');
const fallback = setTimeout(() => el.remove(), 300);
el.addEventListener('animationend', () => { clearTimeout(fallback); el.remove(); }, { once: true });
}
return {
show,
success: (msg, dur) => show(msg, 'success', dur),
error: (msg, dur) => show(msg, 'danger', dur || 5000),
warning: (msg, dur) => show(msg, 'warning', dur),
info: (msg, dur) => show(msg, 'info', dur),
};
})();
// ----------------------------------------------------------
// MODAL
// ----------------------------------------------------------
const modal = (() => {
let _current = null;
function open({ title, body, footer, onClose, size } = {}) {
close(); // vorheriges schließen
const overlay = document.createElement('div');
overlay.className = 'modal-overlay' + (size ? ` modal-overlay--${size}` : '');
overlay.innerHTML = `
${title ? `
` : ''}
${body || ''}
${footer ? `` : ''}
`;
overlay.querySelector('.modal-close-btn')?.addEventListener('click', close);
document.getElementById('modal-container').appendChild(overlay);
document.documentElement.classList.add('modal-open');
_current = { overlay, onClose };
return overlay.querySelector('.modal');
}
function close() {
if (!_current) return;
const { onClose } = _current;
onClose?.();
_current.overlay.remove();
document.documentElement.classList.remove('modal-open');
_current = null;
// iOS Safari setzt den Zoom nach Input-Fokus nicht zurück — Viewport kurz neu setzen
const meta = document.querySelector('meta[name="viewport"]');
if (meta) {
const orig = meta.content;
meta.content = orig + ',maximum-scale=1';
requestAnimationFrame(() => { meta.content = orig; });
}
}
// Bestätigungsdialog
function confirm({ title, message, confirmText = 'OK', cancelText = 'Abbrechen',
danger = false } = {}) {
return new Promise(resolve => {
const m = open({
title,
body: `${message}
`,
footer: `
`,
onClose: () => resolve(false),
});
m.parentElement.querySelector('#modal-cancel').addEventListener('click', () => {
resolve(false); close();
});
m.parentElement.querySelector('#modal-confirm').addEventListener('click', () => {
resolve(true); close();
});
});
}
return { open, close, confirm };
})();
// ----------------------------------------------------------
// LOADING STATE für Buttons
// ----------------------------------------------------------
function setLoading(btn, loading) {
if (loading) {
btn._originalContent = btn.innerHTML;
btn.innerHTML = '';
btn.disabled = true;
} else {
btn.innerHTML = btn._originalContent || btn.innerHTML;
btn.disabled = false;
}
}
// ----------------------------------------------------------
// ASYNC BUTTON: Button-Click → Loader → Ergebnis → Toast
// Verwendung: UI.asyncButton(btn, async () => { await API.something() })
// ----------------------------------------------------------
async function asyncButton(btn, fn, { successMsg, errorMsg } = {}) {
setLoading(btn, true);
try {
const result = await fn();
if (successMsg) toast.success(successMsg);
return result;
} catch (err) {
const msg = errorMsg || err.message || 'Ein Fehler ist aufgetreten.';
toast.error(msg);
throw err;
} finally {
setLoading(btn, false);
}
}
// ----------------------------------------------------------
// FORMULAR-HELPER
// ----------------------------------------------------------
function formData(form) {
const data = {};
new FormData(form).forEach((v, k) => { data[k] = v; });
return data;
}
function setFormError(form, fieldName, message) {
const field = form.querySelector(`[name="${fieldName}"]`);
if (!field) return;
field.classList.add('is-invalid');
let hint = field.parentElement.querySelector('.form-error');
if (!hint) {
hint = document.createElement('span');
hint.className = 'form-error';
field.parentElement.appendChild(hint);
}
hint.textContent = message;
}
function clearFormErrors(form) {
form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
form.querySelectorAll('.form-error').forEach(el => el.remove());
}
// ----------------------------------------------------------
// LEERER ZUSTAND (Empty State)
// ----------------------------------------------------------
function emptyState({ icon, title, text, action } = {}) {
return `
${icon ? `
${icon}
` : ''}
${title ? `
${title}
` : ''}
${text ? `
${text}
` : ''}
${action ? `
${action}
` : ''}
`;
}
// ----------------------------------------------------------
// 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));
}
return {
relative,
format: d => fmtDate.format(new Date(d)),
formatShort: d => fmtDateShort.format(new Date(d)),
};
})();
// ----------------------------------------------------------
// FOTO-VORSCHAU (Input[type=file] → img)
// ----------------------------------------------------------
function setupPhotoPreview(input, imgEl) {
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => { imgEl.src = e.target.result; };
reader.readAsDataURL(file);
});
}
// ----------------------------------------------------------
// SCROLL TO TOP der Seite
// ----------------------------------------------------------
function scrollTop() {
document.getElementById('page-content')?.scrollTo({ top: 0, behavior: 'smooth' });
}
// ----------------------------------------------------------
// SKELETON LOADER (Platzhalter während Laden)
// ----------------------------------------------------------
function skeleton(lines = 3) {
return Array.from({ length: lines }, (_, i) => `
`).join('') + `
`;
}
function escape(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
// ----------------------------------------------------------
// HELP TOOLTIP — inline ? Badge mit Klick-Tooltip
// ----------------------------------------------------------
function help(text) {
return ``;
}
// Event-Delegation für Help-Tooltips — einmalig registrieren
document.addEventListener('click', e => {
const btn = e.target.closest('.by-help-btn');
if (!btn) {
document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove());
return;
}
e.stopPropagation();
document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove());
const tip = document.createElement('div');
tip.className = 'by-help-tooltip';
tip.textContent = btn.dataset.help;
document.body.appendChild(tip);
const r = btn.getBoundingClientRect();
tip.style.top = (r.bottom + window.scrollY + 6) + 'px';
tip.style.left = Math.max(8, r.left + window.scrollX - tip.offsetWidth / 2 + r.width / 2) + 'px';
const maxL = window.innerWidth - tip.offsetWidth - 8;
if (parseFloat(tip.style.left) > maxL) tip.style.left = maxL + 'px';
});
// ----------------------------------------------------------
// SAVE TO ALBUM — Foto/Video nach Kamera-Aufnahme in Mediathek
// anbieten. Nur bei capture-Aufnahmen aufrufen (nicht Galerie).
// Nutzt Web Share API wenn verfügbar, sonst Fallback.
// ----------------------------------------------------------
async function saveToAlbum(file) {
if (!file) return;
// Web Share API mit Datei-Support (iOS Safari 15+, Chrome Android)
if (navigator.canShare && navigator.canShare({ files: [file] })) {
try {
await navigator.share({
files: [file],
title: file.name || 'Foto',
});
} catch (err) {
// AbortError = User hat Sheet geschlossen — kein Fehler anzeigen
if (err?.name !== 'AbortError') {
console.warn('saveToAlbum share error:', err);
}
}
return;
}
// Fallback: direkter Download per
const url = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = url;
a.download = file.name || ('foto_' + Date.now() + (file.type === 'video/mp4' ? '.mp4' : '.jpg'));
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 2000);
}
// Öffentliche API
return {
toast, modal,
setLoading, asyncButton,
formData, setFormError, clearFormErrors,
emptyState, time,
setupPhotoPreview, scrollTop, skeleton,
icon: _svgIcon,
escape, help,
saveToAlbum,
};
})();