- UI.escape() fehlte im Public-API von ui.js (crash nach Profil speichern) - Chip-Nr.-Karte im Hunde-Profil immer sichtbar, auch ohne Wert - "Eintragen"-Button öffnet Inline-Edit-Modal direkt im Profil - SW-Cache by-v143
282 lines
9.9 KiB
JavaScript
282 lines
9.9 KiB
JavaScript
/* ============================================================
|
|
BAN YARO — UI Helpers
|
|
Alle UI-Interaktionen an einem Ort.
|
|
Toast, Modal, Loading, Confirm — einmal gebaut, überall nutzbar.
|
|
============================================================ */
|
|
|
|
const UI = (() => {
|
|
|
|
// ----------------------------------------------------------
|
|
// PHOSPHOR ICON HELPER — erzeugt SVG-String für Templates
|
|
// ----------------------------------------------------------
|
|
function _svgIcon(name, extraClass = '') {
|
|
return `<svg class="ph-icon${extraClass ? ' ' + extraClass : ''}" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// TOAST
|
|
// ----------------------------------------------------------
|
|
const toast = (() => {
|
|
const container = () => document.getElementById('toast-container');
|
|
|
|
function show(message, type = 'default', duration = 3500) {
|
|
const el = document.createElement('div');
|
|
el.className = `toast${type !== 'default' ? ` toast-${type}` : ''}`;
|
|
|
|
const iconName = { success: 'check', danger: 'x', warning: 'warning', info: 'info' }[type];
|
|
el.innerHTML = iconName
|
|
? `${_svgIcon(iconName)}<span>${message}</span>`
|
|
: `<span>${message}</span>`;
|
|
|
|
container().appendChild(el);
|
|
|
|
const timer = setTimeout(() => remove(el), duration);
|
|
el.addEventListener('click', () => { clearTimeout(timer); remove(el); });
|
|
}
|
|
|
|
function remove(el) {
|
|
el.classList.add('removing');
|
|
const fallback = setTimeout(() => el.remove(), 300);
|
|
el.addEventListener('animationend', () => { clearTimeout(fallback); el.remove(); }, { once: true });
|
|
}
|
|
|
|
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 } = {}) {
|
|
close(); // vorheriges schließen
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'modal-overlay';
|
|
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);
|
|
|
|
document.getElementById('modal-container').appendChild(overlay);
|
|
document.body.style.overflow = 'hidden';
|
|
_current = { overlay, onClose };
|
|
|
|
return overlay.querySelector('.modal');
|
|
}
|
|
|
|
function close() {
|
|
if (!_current) return;
|
|
_current.onClose?.();
|
|
_current.overlay.remove();
|
|
document.body.style.overflow = '';
|
|
_current = null;
|
|
}
|
|
|
|
// 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)
|
|
// ----------------------------------------------------------
|
|
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 style="margin-top:var(--space-4)">${action}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// 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) => `
|
|
<div style="height:${i === 0 ? 20 : 14}px; width:${70 + Math.random() * 30}%;
|
|
background:var(--c-surface-2); border-radius:var(--radius-sm);
|
|
margin-bottom:var(--space-2); animation:skeleton-pulse 1.5s ease infinite">
|
|
</div>
|
|
`).join('') + `
|
|
<style>
|
|
@keyframes skeleton-pulse {
|
|
0%,100% { opacity:1 } 50% { opacity:0.4 }
|
|
}
|
|
</style>
|
|
`;
|
|
}
|
|
|
|
function escape(str) {
|
|
if (!str) return '';
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// Öffentliche API
|
|
return {
|
|
toast, modal,
|
|
setLoading, asyncButton,
|
|
formData, setFormError, clearFormErrors,
|
|
emptyState, time,
|
|
setupPhotoPreview, scrollTop, skeleton,
|
|
icon: _svgIcon,
|
|
escape,
|
|
};
|
|
|
|
})();
|