Sprint 0: Design System, App Shell, PWA, zentrales JS-Fundament
This commit is contained in:
parent
756e17faba
commit
84f49fafcf
9 changed files with 2507 additions and 0 deletions
249
backend/static/js/api.js
Normal file
249
backend/static/js/api.js
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/* ============================================================
|
||||
BAN YARO — API Client
|
||||
Zentraler Eingang für ALLE Backend-Kommunikation.
|
||||
Kein fetch() wird außerhalb dieser Datei aufgerufen.
|
||||
============================================================ */
|
||||
|
||||
const API = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Interner HTTP-Kern
|
||||
// ----------------------------------------------------------
|
||||
async function _request(method, path, body = null, options = {}) {
|
||||
const config = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet
|
||||
};
|
||||
|
||||
if (body && !(body instanceof FormData)) {
|
||||
config.body = JSON.stringify(body);
|
||||
} else if (body instanceof FormData) {
|
||||
delete config.headers['Content-Type']; // Browser setzt multipart/form-data
|
||||
config.body = body;
|
||||
}
|
||||
|
||||
// JWT aus localStorage als Bearer (für API-Calls die das brauchen)
|
||||
const token = localStorage.getItem('by_token');
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`/api${path}`, config);
|
||||
} catch (err) {
|
||||
throw new APIError('Keine Verbindung zum Server.', 0, 'network');
|
||||
}
|
||||
|
||||
// 204 No Content
|
||||
if (response.status === 204) return null;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = data?.detail || data?.message || `Fehler ${response.status}`;
|
||||
throw new APIError(message, response.status, data?.code);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Öffentliche HTTP-Methoden
|
||||
// ----------------------------------------------------------
|
||||
const get = (path) => _request('GET', path);
|
||||
const post = (path, body) => _request('POST', path, body);
|
||||
const put = (path, body) => _request('PUT', path, body);
|
||||
const patch = (path, body) => _request('PATCH', path, body);
|
||||
const del = (path) => _request('DELETE', path);
|
||||
const upload = (path, form) => _request('POST', path, form); // FormData
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// AUTH
|
||||
// ----------------------------------------------------------
|
||||
const auth = {
|
||||
login(email, password) {
|
||||
return post('/auth/login', { email, password });
|
||||
},
|
||||
register(email, password, name) {
|
||||
return post('/auth/register', { email, password, name });
|
||||
},
|
||||
logout() {
|
||||
localStorage.removeItem('by_token');
|
||||
return post('/auth/logout');
|
||||
},
|
||||
me() {
|
||||
return get('/auth/me');
|
||||
},
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HUNDE-PROFIL
|
||||
// ----------------------------------------------------------
|
||||
const dogs = {
|
||||
list() { return get('/dogs'); },
|
||||
get(id) { return get(`/dogs/${id}`); },
|
||||
create(data) { return post('/dogs', data); },
|
||||
update(id, data) { return patch(`/dogs/${id}`, data); },
|
||||
delete(id) { return del(`/dogs/${id}`); },
|
||||
uploadPhoto(id, formData) { return upload(`/dogs/${id}/photo`, formData); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAGEBUCH
|
||||
// ----------------------------------------------------------
|
||||
const diary = {
|
||||
list(dogId, params = {}) {
|
||||
const q = new URLSearchParams(params).toString();
|
||||
return get(`/dogs/${dogId}/diary${q ? '?' + q : ''}`);
|
||||
},
|
||||
get(dogId, entryId) { return get(`/dogs/${dogId}/diary/${entryId}`); },
|
||||
create(dogId, data) { return post(`/dogs/${dogId}/diary`, data); },
|
||||
update(dogId, id, data){ return patch(`/dogs/${dogId}/diary/${id}`, data); },
|
||||
delete(dogId, id) { return del(`/dogs/${dogId}/diary/${id}`); },
|
||||
uploadMedia(dogId, id, formData) {
|
||||
return upload(`/dogs/${dogId}/diary/${id}/media`, formData);
|
||||
},
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// GESUNDHEIT
|
||||
// ----------------------------------------------------------
|
||||
const health = {
|
||||
list(dogId) { return get(`/dogs/${dogId}/health`); },
|
||||
create(dogId, data) { return post(`/dogs/${dogId}/health`, data); },
|
||||
update(dogId, id, d) { return patch(`/dogs/${dogId}/health/${id}`, d); },
|
||||
delete(dogId, id) { return del(`/dogs/${dogId}/health/${id}`); },
|
||||
symptomCheck(dogId, symptoms) {
|
||||
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
|
||||
},
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// GIFTKÖDER-ALARM
|
||||
// ----------------------------------------------------------
|
||||
const poison = {
|
||||
listNearby(lat, lon, radius = 5000) {
|
||||
return get(`/poison?lat=${lat}&lon=${lon}&radius=${radius}`);
|
||||
},
|
||||
report(data) { return post('/poison', data); },
|
||||
confirm(id) { return post(`/poison/${id}/confirm`); },
|
||||
resolve(id) { return post(`/poison/${id}/resolve`); },
|
||||
uploadPhoto(id, form){ return upload(`/poison/${id}/photo`, form); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// KARTE & ORTE
|
||||
// ----------------------------------------------------------
|
||||
const places = {
|
||||
listNearby(lat, lon, type = null, radius = 3000) {
|
||||
const params = new URLSearchParams({ lat, lon, radius });
|
||||
if (type) params.set('type', type);
|
||||
return get(`/places?${params}`);
|
||||
},
|
||||
get(id) { return get(`/places/${id}`); },
|
||||
create(data) { return post('/places', data); },
|
||||
rate(id, rating, comment) { return post(`/places/${id}/ratings`, { rating, comment }); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// GASSI-ROUTEN
|
||||
// ----------------------------------------------------------
|
||||
const routes = {
|
||||
listNearby(lat, lon, radius = 10000) {
|
||||
return get(`/routes?lat=${lat}&lon=${lon}&radius=${radius}`);
|
||||
},
|
||||
get(id) { return get(`/routes/${id}`); },
|
||||
create(data) { return post('/routes', data); },
|
||||
rate(id, d) { return post(`/routes/${id}/ratings`, d); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// WETTER
|
||||
// ----------------------------------------------------------
|
||||
const weather = {
|
||||
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUSH NOTIFICATIONS
|
||||
// ----------------------------------------------------------
|
||||
const push = {
|
||||
getVapidKey() { return get('/push/vapid-key'); },
|
||||
subscribe(subscription) { return post('/push/subscribe', subscription); },
|
||||
unsubscribe() { return del('/push/subscribe'); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUSH-SUBSCRIPTION HELPER
|
||||
// ----------------------------------------------------------
|
||||
async function subscribeToPush() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') return null;
|
||||
|
||||
const { vapid_public_key } = await push.getVapidKey();
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
|
||||
const subscription = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: _urlBase64ToUint8Array(vapid_public_key),
|
||||
});
|
||||
|
||||
await push.subscribe(subscription.toJSON());
|
||||
return subscription;
|
||||
}
|
||||
|
||||
function _urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
return new Uint8Array([...raw].map(c => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// GEOLOCATION HELPER
|
||||
// ----------------------------------------------------------
|
||||
function getLocation(options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Geolocation wird nicht unterstützt.'));
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
|
||||
err => reject(err),
|
||||
{ timeout: 8000, maximumAge: 60000, ...options }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ERROR-KLASSE
|
||||
// ----------------------------------------------------------
|
||||
class APIError extends Error {
|
||||
constructor(message, status, code) {
|
||||
super(message);
|
||||
this.name = 'APIError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// Öffentliche API
|
||||
return {
|
||||
get, post, put, patch, del, upload,
|
||||
auth, dogs, diary, health, poison,
|
||||
places, routes, weather, push,
|
||||
subscribeToPush, getLocation,
|
||||
APIError,
|
||||
};
|
||||
|
||||
})();
|
||||
258
backend/static/js/app.js
Normal file
258
backend/static/js/app.js
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/* ============================================================
|
||||
BAN YARO — App Core
|
||||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const App = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// STATE — zentraler App-Zustand
|
||||
// ----------------------------------------------------------
|
||||
const state = {
|
||||
user: null, // eingeloggter User (oder null)
|
||||
dogs: [], // Hunde des Users
|
||||
activeDog: null, // aktuell gewählter Hund
|
||||
page: 'diary', // aktive Seite
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SEITENDEFINITIONEN
|
||||
// Jede Seite: { id, title, load() }
|
||||
// load() wird beim ersten Aufruf einmalig ausgeführt
|
||||
// ----------------------------------------------------------
|
||||
const pages = {
|
||||
diary: { title: 'Tagebuch', module: null },
|
||||
health: { title: 'Gesundheit', module: null },
|
||||
'dog-profile': { title: 'Mein Hund', module: null },
|
||||
map: { title: 'Karte', module: null },
|
||||
routes: { title: 'Routen', module: null },
|
||||
places: { title: 'Orte', module: null },
|
||||
events: { title: 'Events', module: null },
|
||||
poison: { title: 'Giftköder-Alarm', module: null },
|
||||
walks: { title: 'Gassi-Treffen', module: null },
|
||||
sitting: { title: 'Sitting', module: null },
|
||||
forum: { title: 'Forum', module: null },
|
||||
wiki: { title: 'Wiki', module: null },
|
||||
knigge: { title: 'Knigge', module: null },
|
||||
movies: { title: 'Filme', module: null },
|
||||
settings: { title: 'Einstellungen', module: null },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ROUTER
|
||||
// ----------------------------------------------------------
|
||||
function navigate(pageId, pushHistory = true) {
|
||||
if (!pages[pageId]) return;
|
||||
|
||||
// Aktive Seite ausblenden
|
||||
document.querySelector('.page.active')?.classList.remove('active');
|
||||
document.querySelectorAll('.nav-item.active, .sidebar-item.active')
|
||||
.forEach(el => el.classList.remove('active'));
|
||||
|
||||
// Neue Seite einblenden
|
||||
document.getElementById(`page-${pageId}`)?.classList.add('active');
|
||||
|
||||
// Navigation markieren
|
||||
document.querySelectorAll(`[data-page="${pageId}"]`)
|
||||
.forEach(el => el.classList.add('active'));
|
||||
|
||||
// Header-Titel setzen
|
||||
document.getElementById('header-title').textContent = pages[pageId].title;
|
||||
|
||||
// History
|
||||
if (pushHistory) {
|
||||
history.pushState({ page: pageId }, '', `#${pageId}`);
|
||||
}
|
||||
|
||||
state.page = pageId;
|
||||
UI.scrollTop();
|
||||
|
||||
// Seiten-Modul lazy laden (einmalig)
|
||||
_loadPage(pageId);
|
||||
}
|
||||
|
||||
async function _loadPage(pageId) {
|
||||
const page = pages[pageId];
|
||||
if (page.module) {
|
||||
// Bereits geladen → nur refresh aufrufen wenn vorhanden
|
||||
page.module.refresh?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.querySelector(`#page-${pageId} .page-body`);
|
||||
if (!container) return;
|
||||
|
||||
// Skeleton während Laden
|
||||
container.innerHTML = UI.skeleton(4);
|
||||
|
||||
try {
|
||||
// Seiten-Script dynamisch laden
|
||||
await _loadScript(`/js/pages/${pageId}.js`);
|
||||
const mod = window[`Page_${pageId.replace(/-/g, '_')}`];
|
||||
if (mod?.init) {
|
||||
await mod.init(container, state);
|
||||
page.module = mod;
|
||||
} else {
|
||||
// Platzhalter wenn Seite noch nicht gebaut
|
||||
container.innerHTML = UI.emptyState({
|
||||
icon: '🚧',
|
||||
title: pages[pageId].title,
|
||||
text: 'Diese Seite ist noch in Entwicklung.',
|
||||
});
|
||||
page.module = {}; // verhindert erneutes Laden
|
||||
}
|
||||
} catch {
|
||||
container.innerHTML = UI.emptyState({
|
||||
icon: '🚧',
|
||||
title: pages[pageId].title,
|
||||
text: 'Diese Seite ist noch in Entwicklung.',
|
||||
});
|
||||
page.module = {};
|
||||
}
|
||||
}
|
||||
|
||||
function _loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// NAVIGATION EVENTS
|
||||
// ----------------------------------------------------------
|
||||
function _bindNavigation() {
|
||||
// Bottom Nav + Sidebar Klicks
|
||||
document.addEventListener('click', e => {
|
||||
const item = e.target.closest('[data-page]');
|
||||
if (item) {
|
||||
navigate(item.dataset.page);
|
||||
return;
|
||||
}
|
||||
|
||||
// + Button
|
||||
if (e.target.closest('#nav-add')) {
|
||||
_showQuickAdd();
|
||||
}
|
||||
});
|
||||
|
||||
// Browser Back/Forward
|
||||
window.addEventListener('popstate', e => {
|
||||
const page = e.state?.page || 'diary';
|
||||
navigate(page, false);
|
||||
});
|
||||
|
||||
// Initial: URL-Hash auslesen
|
||||
const hash = location.hash.replace('#', '');
|
||||
if (hash && pages[hash]) {
|
||||
navigate(hash, false);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SCHNELL-HINZUFÜGEN (+ Button)
|
||||
// ----------------------------------------------------------
|
||||
function _showQuickAdd() {
|
||||
UI.modal.open({
|
||||
title: 'Was möchtest du hinzufügen?',
|
||||
body: `
|
||||
<div class="flex flex-col gap-3">
|
||||
<button class="btn btn-secondary w-full" data-quick="diary">
|
||||
📖 Tagebuch-Eintrag
|
||||
</button>
|
||||
<button class="btn btn-secondary w-full" data-quick="health">
|
||||
💉 Gesundheits-Eintrag
|
||||
</button>
|
||||
<button class="btn btn-danger w-full" data-quick="poison">
|
||||
⚠️ Giftköder melden
|
||||
</button>
|
||||
<button class="btn btn-secondary w-full" data-quick="place">
|
||||
📍 Ort hinzufügen
|
||||
</button>
|
||||
<button class="btn btn-nature w-full" data-quick="walk">
|
||||
🦮 Gassi-Treffen erstellen
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
// Quick-Add Aktionen
|
||||
document.querySelector('#modal-container').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-quick]');
|
||||
if (!btn) return;
|
||||
UI.modal.close();
|
||||
const action = btn.dataset.quick;
|
||||
if (action === 'diary') { navigate('diary'); pages['diary'].module?.openNew?.(); }
|
||||
if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); }
|
||||
if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); }
|
||||
if (action === 'place') { navigate('places'); pages['places'].module?.openNew?.(); }
|
||||
if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); }
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// AUTH
|
||||
// ----------------------------------------------------------
|
||||
async function _checkAuth() {
|
||||
try {
|
||||
const user = await API.auth.me();
|
||||
state.user = user;
|
||||
_onLoggedIn();
|
||||
} catch {
|
||||
_onLoggedOut();
|
||||
}
|
||||
}
|
||||
|
||||
function _onLoggedIn() {
|
||||
document.getElementById('sidebar-username').textContent = state.user.name;
|
||||
_loadDogs();
|
||||
}
|
||||
|
||||
function _onLoggedOut() {
|
||||
state.user = null;
|
||||
// Zeige Login wenn nötig
|
||||
// Für MVP: direkte Weiterleitung zu Einstellungen/Login
|
||||
}
|
||||
|
||||
async function _loadDogs() {
|
||||
try {
|
||||
state.dogs = await API.dogs.list();
|
||||
if (state.dogs.length > 0) {
|
||||
state.activeDog = state.dogs[0];
|
||||
}
|
||||
// Seitenmodule über neuen Hund informieren
|
||||
_notifyDogChange();
|
||||
} catch { /* kein Hund vorhanden */ }
|
||||
}
|
||||
|
||||
function _notifyDogChange() {
|
||||
// Alle geladenen Seiten-Module über Hundwechsel informieren
|
||||
Object.values(pages).forEach(p => p.module?.onDogChange?.(state.activeDog));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INITIALISIERUNG
|
||||
// ----------------------------------------------------------
|
||||
async function init() {
|
||||
_bindNavigation();
|
||||
await _checkAuth();
|
||||
|
||||
// Erste Seite laden (Hash oder Standard: diary)
|
||||
const startPage = location.hash.replace('#', '') || 'diary';
|
||||
navigate(pages[startPage] ? startPage : 'diary', false);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ÖFFENTLICHE API
|
||||
// (andere Module können App.state, App.navigate etc. nutzen)
|
||||
// ----------------------------------------------------------
|
||||
return { init, navigate, state };
|
||||
|
||||
})();
|
||||
|
||||
// App starten
|
||||
document.addEventListener('DOMContentLoaded', () => App.init());
|
||||
264
backend/static/js/ui.js
Normal file
264
backend/static/js/ui.js
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
/* ============================================================
|
||||
BAN YARO — UI Helpers
|
||||
Alle UI-Interaktionen an einem Ort.
|
||||
Toast, Modal, Loading, Confirm — einmal gebaut, überall nutzbar.
|
||||
============================================================ */
|
||||
|
||||
const UI = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 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 icon = { success: '✓', danger: '✕', warning: '⚠', info: 'ℹ' }[type] || '';
|
||||
el.innerHTML = icon
|
||||
? `<span style="font-size:1.1em">${icon}</span><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');
|
||||
el.addEventListener('animationend', () => 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">✕</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 === overlay) 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', () => {
|
||||
close(); resolve(false);
|
||||
});
|
||||
m.parentElement.querySelector('#modal-confirm').addEventListener('click', () => {
|
||||
close(); resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
// Öffentliche API
|
||||
return {
|
||||
toast, modal,
|
||||
setLoading, asyncButton,
|
||||
formData, setFormError, clearFormErrors,
|
||||
emptyState, time,
|
||||
setupPhotoPreview, scrollTop, skeleton,
|
||||
};
|
||||
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue