Release v1.3.0
This commit is contained in:
commit
15e2446ea7
68 changed files with 16373 additions and 465 deletions
|
|
@ -6,69 +6,84 @@
|
|||
|
||||
const API = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Request-Deduplication: gleiche GET-URL nur einmal in-flight
|
||||
// ----------------------------------------------------------
|
||||
const _inflight = new Map();
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Interner HTTP-Kern
|
||||
// ----------------------------------------------------------
|
||||
async function _request(method, path, body = null, options = {}) {
|
||||
async function _doRequest(method, path, body, attempt) {
|
||||
const config = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet
|
||||
credentials: 'include',
|
||||
};
|
||||
|
||||
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
|
||||
delete config.headers['Content-Type'];
|
||||
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}`;
|
||||
}
|
||||
if (token) config.headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`/api${path}`, config);
|
||||
} catch (err) {
|
||||
const offlineMsg = 'Kein Internet — du bist offline.';
|
||||
if (window.UI && UI.toast) UI.toast.warning(offlineMsg, 4000);
|
||||
throw new APIError(offlineMsg, 0, 'network');
|
||||
} catch {
|
||||
// Netzwerkfehler: bei GET bis zu 2 Retry-Versuche
|
||||
if (method === 'GET' && attempt < 2) {
|
||||
await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt)));
|
||||
return _doRequest(method, path, body, attempt + 1);
|
||||
}
|
||||
const msg = 'Kein Internet — du bist offline.';
|
||||
if (window.UI?.toast) UI.toast.warning(msg, 4000);
|
||||
throw new APIError(msg, 0, 'network');
|
||||
}
|
||||
|
||||
// 204 No Content
|
||||
if (response.status === 204) return null;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
try { data = await response.json(); } catch { data = null; }
|
||||
|
||||
if (!response.ok) {
|
||||
const message = data?.detail || data?.message || `Fehler ${response.status}`;
|
||||
// SW gibt bei Offline-Anfragen 503 + 'Offline — keine Verbindung.' zurück
|
||||
const isOffline = response.status === 503 && message.startsWith('Offline');
|
||||
if (isOffline && window.UI && UI.toast) {
|
||||
UI.toast.warning('Kein Internet — du bist offline.', 4000);
|
||||
const isSwOffline = response.status === 503 && message.startsWith('Offline');
|
||||
|
||||
// Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)
|
||||
if (method === 'GET' && response.status >= 500 && !isSwOffline && attempt < 2) {
|
||||
await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt)));
|
||||
return _doRequest(method, path, body, attempt + 1);
|
||||
}
|
||||
throw new APIError(message, response.status, isOffline ? 'network' : data?.code);
|
||||
|
||||
if (isSwOffline && window.UI?.toast) UI.toast.warning('Kein Internet — du bist offline.', 4000);
|
||||
throw new APIError(message, response.status, isSwOffline ? 'network' : data?.code);
|
||||
}
|
||||
|
||||
// SW hat die Anfrage in die Offline-Queue eingereiht
|
||||
if (data?._queued) {
|
||||
if (typeof UI !== 'undefined' && UI.toast) {
|
||||
if (typeof UI !== 'undefined' && UI.toast)
|
||||
UI.toast.info('Offline gespeichert — wird automatisch synchronisiert');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function _request(method, path, body = null) {
|
||||
// GET-Deduplication: laufende identische Anfragen zusammenfassen
|
||||
if (method === 'GET') {
|
||||
if (_inflight.has(path)) return _inflight.get(path);
|
||||
const promise = _doRequest('GET', path, null, 0).finally(() => _inflight.delete(path));
|
||||
_inflight.set(path, promise);
|
||||
return promise;
|
||||
}
|
||||
return _doRequest(method, path, body, 0);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Öffentliche HTTP-Methoden
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -195,6 +210,17 @@ const API = (() => {
|
|||
create(data) { return post('/tieraerzte', data); },
|
||||
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
|
||||
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
|
||||
myFavorite() { return get('/tieraerzte/my-favorite'); },
|
||||
toggleFavorite(id) { return post(`/tieraerzte/${id}/favorite`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// GESUNDHEITSDOKUMENTE
|
||||
// ----------------------------------------------------------
|
||||
const healthDocs = {
|
||||
list(dogId) { return get(`/health-docs?dog_id=${dogId}`); },
|
||||
upload(formData) { return upload('/health-docs/upload', formData); },
|
||||
delete(id) { return del(`/health-docs/${id}`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -415,8 +441,9 @@ const API = (() => {
|
|||
// WETTER
|
||||
// ----------------------------------------------------------
|
||||
const weather = {
|
||||
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
|
||||
get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); },
|
||||
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
|
||||
get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); },
|
||||
forecast(lat, lon) { return get(`/weather/forecast?lat=${lat}&lon=${lon}`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -712,7 +739,7 @@ const API = (() => {
|
|||
// Öffentliche API
|
||||
return {
|
||||
get, post, put, patch, del, upload,
|
||||
auth, dogs, diary, health, tieraerzte, poison,
|
||||
auth, dogs, diary, health, tieraerzte, healthDocs, poison,
|
||||
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
|
||||
breeder, litters, breederPhotos, zuchthunde, zuchtKi,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
|
||||
const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||
|
||||
const App = (() => {
|
||||
|
|
@ -70,6 +70,12 @@ const App = (() => {
|
|||
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
|
||||
'zucht-profil': { title: 'Hunde-Profil', module: null },
|
||||
gruender: { title: '100 Gründer', module: null },
|
||||
jobs: { title: 'Wir suchen dich', module: null },
|
||||
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
|
||||
recalls: { title: 'Rückrufe', module: null },
|
||||
adoption: { title: 'Adoption', module: null },
|
||||
playdate: { title: 'Playdate', module: null, requiresAuth: true },
|
||||
wetter: { title: 'Wetter', module: null },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -85,6 +91,7 @@ const App = (() => {
|
|||
sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null },
|
||||
uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' },
|
||||
notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null },
|
||||
playdate: { icon: 'paw-print', text: 'Finde Spielkameraden für deinen Hund in der Nähe und verabrede ein Treffen.', preview: null },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -92,6 +99,7 @@ const App = (() => {
|
|||
// ----------------------------------------------------------
|
||||
function navigate(pageId, pushHistory = true, params = {}) {
|
||||
if (!pages[pageId]) return;
|
||||
if (window.Worlds?._visible) window.Worlds.hide();
|
||||
|
||||
// Aktive Seite ausblenden
|
||||
document.querySelector('.page.active')?.classList.remove('active');
|
||||
|
|
@ -564,7 +572,7 @@ const App = (() => {
|
|||
banner.style.display = 'flex';
|
||||
|
||||
document.getElementById('verify-resend-btn')?.addEventListener('click', async () => {
|
||||
await API.post('/auth/resend-verification', {});
|
||||
await API.post('/auth/resend-verification', { email: state.user.email });
|
||||
UI.toast.success('Bestätigungs-Mail erneut gesendet.');
|
||||
}, { once: true });
|
||||
|
||||
|
|
@ -846,6 +854,9 @@ const App = (() => {
|
|||
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome';
|
||||
// Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc.
|
||||
navigate(state.user ? startPage : 'welcome', false, hashParams);
|
||||
|
||||
// Drei Welten nach initialer Navigation starten (damit hide() in navigate() sie nicht gleich killt)
|
||||
if (window.Worlds) window.Worlds.init(state);
|
||||
}
|
||||
|
||||
async function _handleInvite(token) {
|
||||
|
|
@ -919,6 +930,8 @@ const App = (() => {
|
|||
|
||||
})();
|
||||
|
||||
window.App = App; // Worlds kann App.navigate() aufrufen
|
||||
|
||||
// App starten
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
App.init();
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@ window.Page_admin = (() => {
|
|||
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
|
||||
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
|
||||
{ id: 'zuchter', label: 'Züchter', icon: 'certificate' },
|
||||
{ id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' },
|
||||
{ id: 'forum', label: 'Forum', icon: 'chat-circle-dots' },
|
||||
{ id: 'social', label: 'Social Media', icon: 'camera' },
|
||||
{ id: 'analytics', label: 'Analytics', icon: 'target' },
|
||||
{ id: 'system', label: 'System', icon: 'gear' },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
|
||||
{ id: 'jobs', label: 'Scheduler', icon: 'clock' },
|
||||
{ id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' },
|
||||
{ id: 'partner', label: 'Partner', icon: 'handshake' },
|
||||
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
|
||||
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
||||
|
|
@ -47,6 +48,9 @@ window.Page_admin = (() => {
|
|||
// ------------------------------------------------------------------
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<!-- Action Items -->
|
||||
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="by-tabs adm-tabs" id="adm-tabs">
|
||||
${TABS.map(t => `
|
||||
|
|
@ -72,9 +76,68 @@ window.Page_admin = (() => {
|
|||
});
|
||||
});
|
||||
|
||||
_renderActionItems();
|
||||
_renderTab();
|
||||
}
|
||||
|
||||
async function _renderActionItems() {
|
||||
const el = _container.querySelector('#adm-action-items');
|
||||
if (!el) return;
|
||||
let d;
|
||||
try { d = await API.get('/admin/action-items'); } catch { return; }
|
||||
|
||||
const items = [
|
||||
{ key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' },
|
||||
{ key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' },
|
||||
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
|
||||
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
|
||||
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
|
||||
];
|
||||
|
||||
const open = items.filter(i => d[i.key] > 0);
|
||||
const usersToday = d.users_today || 0;
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
|
||||
background:var(--c-surface);border:1px solid var(--c-border);
|
||||
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4)">
|
||||
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
|
||||
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
|
||||
${UI.icon('check-square')} Zu erledigen
|
||||
</span>
|
||||
${open.length === 0
|
||||
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
|
||||
${UI.icon('check-circle')} Alles erledigt
|
||||
</span>`
|
||||
: open.map(i => `
|
||||
<button data-action-tab="${i.tab}"
|
||||
style="display:inline-flex;align-items:center;gap:4px;
|
||||
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
|
||||
border:1px solid var(--c-warning,#e65100);border-radius:999px;
|
||||
padding:2px 10px;font-size:var(--text-xs);font-weight:700;cursor:pointer">
|
||||
${UI.icon(i.icon)} ${i.label}
|
||||
<span style="background:var(--c-warning,#e65100);color:#fff;
|
||||
border-radius:999px;padding:0 6px;margin-left:2px">
|
||||
${d[i.key]}
|
||||
</span>
|
||||
</button>`).join('')
|
||||
}
|
||||
<span style="margin-left:auto;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
${UI.icon('user-plus')} ${usersToday} neue Nutzer heute
|
||||
</span>
|
||||
</div>`;
|
||||
|
||||
el.querySelectorAll('[data-action-tab]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_tab = btn.dataset.actionTab;
|
||||
_container.querySelectorAll('#adm-tabs .by-tab').forEach(b =>
|
||||
b.classList.toggle('active', b.dataset.tab === _tab)
|
||||
);
|
||||
_renderTab();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function _renderTab() {
|
||||
const el = _container.querySelector('#adm-content');
|
||||
if (!el) return;
|
||||
|
|
@ -93,6 +156,7 @@ window.Page_admin = (() => {
|
|||
case 'partner': await _renderPartner(el); break;
|
||||
case 'outreach': await _renderOutreach(el); break;
|
||||
case 'audit': await _renderAudit(el); break;
|
||||
case 'bewerbungen': await _renderBewerbungen(el); break;
|
||||
}
|
||||
} catch (e) {
|
||||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
||||
|
|
@ -1396,6 +1460,43 @@ window.Page_admin = (() => {
|
|||
// ------------------------------------------------------------------
|
||||
// TAB: MODERATION
|
||||
// ------------------------------------------------------------------
|
||||
function _ageLabel(createdAt) {
|
||||
if (!createdAt) return '';
|
||||
const h = (Date.now() - new Date(createdAt + 'Z').getTime()) / 3600000;
|
||||
const overdue = h >= 24;
|
||||
const label = h < 1 ? '<1h' : h < 24 ? `${Math.floor(h)}h` : `${Math.floor(h/24)}d ${Math.floor(h%24)}h`;
|
||||
return `<span style="font-size:var(--text-xs);font-weight:700;padding:1px 7px;border-radius:999px;
|
||||
margin-left:6px;${overdue
|
||||
? 'background:#fef2f2;color:#dc2626;border:1px solid #fca5a5'
|
||||
: 'background:var(--c-surface-2);color:var(--c-text-muted);border:1px solid var(--c-border)'}">
|
||||
${overdue ? '⚠️ ' : ''}${label}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function _historySection(label, items, renderItem) {
|
||||
const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`;
|
||||
return `
|
||||
<details style="margin-bottom:var(--space-4)">
|
||||
<summary style="cursor:pointer;list-style:none;display:flex;align-items:center;gap:var(--space-2);
|
||||
font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
|
||||
text-transform:uppercase;letter-spacing:.06em;padding:var(--space-2) 0;
|
||||
border-top:1px solid var(--c-border)">
|
||||
${UI.icon('clock-countdown')} ${items.length} erledigte ${label}
|
||||
<svg class="ph-icon" style="margin-left:auto;transition:transform .2s" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#caret-down"></use>
|
||||
</svg>
|
||||
</summary>
|
||||
<div style="margin-top:var(--space-2);display:flex;flex-direction:column;gap:var(--space-1)">
|
||||
${items.map(item => `
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||||
border-radius:var(--radius-sm);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
|
||||
${renderItem(item)}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
async function _renderModeration(el) {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||
|
|
@ -1410,12 +1511,52 @@ window.Page_admin = (() => {
|
|||
async function _loadModeration(el) {
|
||||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||||
|
||||
const [zuchter, fotos] = await Promise.all([
|
||||
const [zuchter, fotos, reports, poiEdits] = await Promise.all([
|
||||
API.get('/wiki/zuchter/pending').catch(() => []),
|
||||
API.get('/wiki/foto-submissions').catch(() => []),
|
||||
API.get('/moderation/reports').catch(() => []),
|
||||
API.get('/moderation/poi-edits').catch(() => []),
|
||||
]);
|
||||
const zuchterPending = zuchter.filter(z => !z.verified);
|
||||
const zuchterDone = zuchter.filter(z => z.verified);
|
||||
const fotosPending = fotos.filter(f => f.status === 'pending');
|
||||
const fotosDone = fotos.filter(f => f.status !== 'pending');
|
||||
const reportsPending = reports.filter(r => !r.resolved);
|
||||
const reportsDone = reports.filter(r => r.resolved);
|
||||
const poiPending = poiEdits.filter(e => e.status === 'pending');
|
||||
const poiDone = poiEdits.filter(e => e.status !== 'pending');
|
||||
|
||||
let html = '';
|
||||
const modItems = [
|
||||
{ label: 'Züchter-Einreichungen', count: zuchterPending.length, icon: 'certificate' },
|
||||
{ label: 'Foto-Einreichungen', count: fotosPending.length, icon: 'image' },
|
||||
{ label: 'Forum-Meldungen', count: reportsPending.length, icon: 'warning' },
|
||||
{ label: 'POI-Korrekturen', count: poiPending.length, icon: 'map-pin' },
|
||||
].filter(i => i.count > 0);
|
||||
|
||||
let html = `
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
|
||||
background:var(--c-surface);border:1px solid var(--c-border);
|
||||
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4);
|
||||
margin-bottom:var(--space-4)">
|
||||
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
|
||||
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
|
||||
${UI.icon('check-square')} Zu erledigen
|
||||
</span>
|
||||
${modItems.length === 0
|
||||
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
|
||||
${UI.icon('check-circle')} Alles erledigt
|
||||
</span>`
|
||||
: modItems.map(i => `
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;
|
||||
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
|
||||
border:1px solid var(--c-warning,#e65100);border-radius:999px;
|
||||
padding:2px 10px;font-size:var(--text-xs);font-weight:700">
|
||||
${UI.icon(i.icon)} ${i.label}
|
||||
<strong style="background:var(--c-warning,#e65100);color:#fff;
|
||||
border-radius:999px;padding:0 6px;margin-left:2px">${i.count}</strong>
|
||||
</span>`).join('')
|
||||
}
|
||||
</div>`;
|
||||
|
||||
// --- Züchter-Einreichungen ---
|
||||
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
|
|
@ -1423,23 +1564,24 @@ window.Page_admin = (() => {
|
|||
margin-bottom:var(--space-3)">
|
||||
Züchter-Einreichungen
|
||||
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
|
||||
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchter.length}</span>
|
||||
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchterPending.length}</span>
|
||||
</h3>`;
|
||||
|
||||
if (!zuchter.length) {
|
||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-6)">Keine ausstehenden Einreichungen.</p>`;
|
||||
if (!zuchterPending.length) {
|
||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Einreichungen.</p>`;
|
||||
} else {
|
||||
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-6)"><div class="adm-table-scroll"><table class="adm-table">
|
||||
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-3)"><div class="adm-table-scroll"><table class="adm-table">
|
||||
<thead><tr style="background:var(--c-surface-2);text-align:left">
|
||||
<th class="adm-th">Rasse</th><th class="adm-th">Name / Zwingername</th>
|
||||
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Website</th><th class="adm-th"></th>
|
||||
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Alter</th><th class="adm-th">Website</th><th class="adm-th"></th>
|
||||
</tr></thead><tbody>
|
||||
${zuchter.map((z, i) => `
|
||||
${zuchterPending.map((z, i) => `
|
||||
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
|
||||
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(z.rasse_slug)}</td>
|
||||
<td class="adm-td">${_esc(z.name)}${z.zwingername ? `<br><span style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(z.zwingername)}</span>` : ''}</td>
|
||||
<td class="adm-td">${_esc([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))}</td>
|
||||
<td class="adm-td">${z.vdh_mitglied ? `<span style="color:var(--c-success);display:flex;align-items:center;gap:2px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> VDH</span>` : '—'}</td>
|
||||
<td class="adm-td">${_ageLabel(z.created_at)}</td>
|
||||
<td class="adm-td">${z.website ? `<a href="${_esc(z.website)}" target="_blank" style="color:var(--c-primary);font-size:var(--text-xs)">Link</a>` : '—'}</td>
|
||||
<td class="adm-td" style="text-align:right;white-space:nowrap">
|
||||
<button class="btn btn-sm btn-primary adm-zuchter-approve" data-id="${z.id}" style="margin-right:4px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
|
||||
|
|
@ -1448,6 +1590,10 @@ window.Page_admin = (() => {
|
|||
</tr>`).join('')}
|
||||
</tbody></table></div></div>`;
|
||||
}
|
||||
// Züchter-History
|
||||
if (zuchterDone.length) html += _historySection('Züchter-Einreichungen', zuchterDone,
|
||||
z => `<span style="font-weight:600">${_esc(z.name)}</span> · ${_esc(z.rasse_slug)} ·
|
||||
${UI.icon('check-circle')} ${_esc(z.verified_by_name||'?')} · ${(z.verified_at||'').slice(0,10)}`);
|
||||
|
||||
// --- Wiki-Foto-Einreichungen ---
|
||||
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
|
|
@ -1455,19 +1601,20 @@ window.Page_admin = (() => {
|
|||
margin-bottom:var(--space-3)">
|
||||
Wiki-Foto-Einreichungen
|
||||
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
|
||||
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotos.length}</span>
|
||||
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotosPending.length}</span>
|
||||
</h3>`;
|
||||
|
||||
if (!fotos.length) {
|
||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden Foto-Einreichungen.</p>`;
|
||||
if (!fotosPending.length) {
|
||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Foto-Einreichungen.</p>`;
|
||||
} else {
|
||||
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4)">
|
||||
${fotos.map(f => `
|
||||
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4);margin-bottom:var(--space-3)">
|
||||
${fotosPending.map(f => `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<img src="${_esc(f.foto_url)}" alt=""
|
||||
style="width:100%;height:140px;object-fit:cover;border-radius:var(--radius-md);margin-bottom:var(--space-3)">
|
||||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">${_esc(f.rasse_name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-3)">von ${_esc(f.user_name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">von ${_esc(f.user_name)}</div>
|
||||
<div style="margin-bottom:var(--space-3)">${_ageLabel(f.created_at)}</div>
|
||||
${f.aktuell_foto ? `<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
|
||||
style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm);
|
||||
opacity:.5;margin-bottom:var(--space-2)">
|
||||
|
|
@ -1480,6 +1627,111 @@ window.Page_admin = (() => {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
// Fotos-History
|
||||
if (fotosDone.length) html += _historySection('Foto-Einreichungen', fotosDone,
|
||||
f => `<img src="${_esc(f.foto_url)}" style="width:32px;height:32px;object-fit:cover;border-radius:4px;vertical-align:middle;margin-right:6px">
|
||||
<span style="font-weight:600">${_esc(f.rasse_name||'?')}</span> · von ${_esc(f.user_name||'?')} ·
|
||||
${f.status==='approved' ? `${UI.icon('check-circle')} genehmigt` : `${UI.icon('x-circle')} abgelehnt`}
|
||||
${f.reviewed_by_name ? ` von ${_esc(f.reviewed_by_name)}` : ''} · ${(f.reviewed_at||'').slice(0,10)}`);
|
||||
|
||||
// --- Forum-Meldungen ---
|
||||
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
|
||||
margin:var(--space-4) 0 var(--space-3)">
|
||||
Forum-Meldungen
|
||||
<span style="background:${reportsPending.length ? 'var(--c-danger)' : 'var(--c-primary)'};color:#fff;
|
||||
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
|
||||
${reportsPending.length}
|
||||
</span>
|
||||
</h3>`;
|
||||
if (!reportsPending.length) {
|
||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine offenen Meldungen.</p>`;
|
||||
} else {
|
||||
html += `<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-bottom:var(--space-3)">
|
||||
${reportsPending.map(r => `
|
||||
<div class="card" style="padding:var(--space-4);border-left:3px solid var(--c-danger)">
|
||||
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
|
||||
${_esc(r.target_type)} #${r.target_id} · Gemeldet von <strong>${_esc(r.melder_name || '?')}</strong>
|
||||
${_ageLabel(r.created_at)}
|
||||
</div>
|
||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);margin-bottom:var(--space-1)">
|
||||
Grund: ${_esc(r.grund)}
|
||||
</div>
|
||||
${r.content_preview ? `
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||||
border-radius:var(--radius-sm)">${_esc(r.content_preview)}</div>` : ''}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary adm-mod-resolve" data-rid="${r.id}" title="Als erledigt markieren">
|
||||
${UI.icon('check')}
|
||||
</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Meldungen-History
|
||||
if (reportsDone.length) html += _historySection('Forum-Meldungen', reportsDone,
|
||||
r => `${_esc(r.target_type)} #${r.target_id} · ${_esc(r.grund)} · Gemeldet von ${_esc(r.melder_name||'?')} ·
|
||||
${UI.icon('check-circle')} ${_esc(r.resolved_by_name||'?')} · ${(r.resolved_at||'').slice(0,10)}`);
|
||||
|
||||
// --- POI-Korrekturen ---
|
||||
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
|
||||
margin:var(--space-2) 0 var(--space-3)">
|
||||
POI-Korrekturen
|
||||
<span style="background:${poiPending.length ? 'var(--c-warning,#e65100)' : 'var(--c-primary)'};color:#fff;
|
||||
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
|
||||
${poiPending.length}
|
||||
</span>
|
||||
</h3>`;
|
||||
if (!poiPending.length) {
|
||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden POI-Korrekturen.</p>`;
|
||||
} else {
|
||||
html += `<div class="card adm-table-card"><div class="adm-table-scroll">
|
||||
<table class="adm-table">
|
||||
<thead><tr style="background:var(--c-surface-2);text-align:left">
|
||||
<th class="adm-th">Ort</th>
|
||||
<th class="adm-th">Feld</th>
|
||||
<th class="adm-th">Alt</th>
|
||||
<th class="adm-th">Neu</th>
|
||||
<th class="adm-th">Von</th>
|
||||
<th class="adm-th">Alter</th>
|
||||
<th class="adm-th"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${poiPending.map((e, i) => `
|
||||
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
|
||||
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(e.poi_name || `OSM #${e.osm_id}`)}</td>
|
||||
<td class="adm-td"><code style="font-size:var(--text-xs)">${_esc(e.field)}</code></td>
|
||||
<td class="adm-td" style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(e.old_value || '—')}</td>
|
||||
<td class="adm-td" style="font-size:var(--text-xs)">${_esc(e.new_value || '—')}</td>
|
||||
<td class="adm-td" style="color:var(--c-text-muted)">${_esc(e.einreicher_name || '?')}</td>
|
||||
<td class="adm-td">${_ageLabel(e.created_at)}</td>
|
||||
<td class="adm-td" style="text-align:right;white-space:nowrap">
|
||||
<button class="btn btn-sm btn-primary adm-poi-approve" data-id="${e.id}" style="margin-right:4px">
|
||||
${UI.icon('check')}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost adm-poi-reject" data-id="${e.id}" style="color:var(--c-danger)">
|
||||
${UI.icon('x')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>`;
|
||||
}
|
||||
// POI-History
|
||||
if (poiDone.length) html += _historySection('POI-Korrekturen', poiDone,
|
||||
e => `<span style="font-weight:600">${_esc(e.poi_name||`OSM #${e.osm_id}`)}</span> ·
|
||||
<code style="font-size:var(--text-xs)">${_esc(e.field)}</code>:
|
||||
<span style="text-decoration:line-through;color:var(--c-text-muted)">${_esc(e.old_value||'—')}</span> →
|
||||
${_esc(e.new_value||'—')} ·
|
||||
${e.status==='approved' ? `${UI.icon('check-circle')} freigegeben` : `${UI.icon('x-circle')} abgelehnt`}
|
||||
${e.mod_name ? ` von ${_esc(e.mod_name)}` : ''} · ${(e.resolved_at||'').slice(0,10)}`);
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Züchter freigeben
|
||||
|
|
@ -1518,6 +1770,41 @@ window.Page_admin = (() => {
|
|||
await _loadModeration(el);
|
||||
});
|
||||
});
|
||||
|
||||
// Forum-Meldung erledigen
|
||||
el.querySelectorAll('.adm-mod-resolve').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.patch(`/moderation/reports/${btn.dataset.rid}`, {});
|
||||
await _loadModeration(el);
|
||||
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
|
||||
// POI-Korrektur freigeben
|
||||
el.querySelectorAll('.adm-poi-approve').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'approve' });
|
||||
UI.toast.success('Korrektur übernommen.');
|
||||
await _loadModeration(el);
|
||||
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
|
||||
// POI-Korrektur ablehnen
|
||||
el.querySelectorAll('.adm-poi-reject').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'reject' });
|
||||
UI.toast.success('Korrektur abgelehnt.');
|
||||
await _loadModeration(el);
|
||||
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
|
@ -2132,8 +2419,10 @@ window.Page_admin = (() => {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${log.map(l => `
|
||||
<tr style="border-bottom:1px solid var(--c-border)">
|
||||
${log.map((l, i) => `
|
||||
<tr data-log-idx="${i}" style="border-bottom:1px solid var(--c-border);cursor:pointer"
|
||||
onmouseover="this.style.background='var(--c-surface-2)'"
|
||||
onmouseout="this.style.background=''">
|
||||
<td style="padding:var(--space-2)">${accountBadge(l.from_account)}</td>
|
||||
<td style="padding:var(--space-2)">${_esc(l.recipient)}</td>
|
||||
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
|
||||
|
|
@ -2147,6 +2436,28 @@ window.Page_admin = (() => {
|
|||
</div>
|
||||
`;
|
||||
|
||||
// Log-Zeile: Mail-Inhalt anzeigen
|
||||
el.querySelectorAll('tr[data-log-idx]').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
const l = log[Number(row.dataset.logIdx)];
|
||||
if (!l) return;
|
||||
UI.modal.open({
|
||||
title: _esc(l.subject),
|
||||
body: `
|
||||
<div style="margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--c-text-muted)">
|
||||
<strong>An:</strong> ${_esc(l.recipient)} ·
|
||||
<strong>Von:</strong> ${_esc(l.from_account)}@banyaro.app ·
|
||||
${(l.sent_at||'').slice(0,16).replace('T',' ')}
|
||||
</div>
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;font-size:var(--text-sm);
|
||||
background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||
padding:var(--space-3);max-height:60vh;overflow-y:auto;
|
||||
color:var(--c-text)">${_esc(l.body || '(kein Text gespeichert)')}</pre>`,
|
||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Vorlage in Compose laden
|
||||
function _loadTplIntoCompose(id) {
|
||||
const tpl = templates.find(t => t.id === id);
|
||||
|
|
@ -2375,6 +2686,129 @@ window.Page_admin = (() => {
|
|||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// BEWERBUNGEN — Social-Media-Job
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderBewerbungen(el) {
|
||||
let _statusFilter = 'pending';
|
||||
|
||||
async function _load() {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4);flex-wrap:wrap;align-items:center">
|
||||
${['pending','reviewing','accepted','rejected','alle'].map(s => `
|
||||
<button class="btn btn-sm ${s===_statusFilter?'btn-primary':'btn-ghost'} adm-bew-filter" data-s="${s}">
|
||||
${s==='pending' ? `${UI.icon('clock')} Neu`
|
||||
: s==='reviewing' ? `${UI.icon('magnifying-glass')} In Prüfung`
|
||||
: s==='accepted' ? `${UI.icon('check-circle')} Angenommen`
|
||||
: s==='rejected' ? `${UI.icon('x')} Abgelehnt`
|
||||
: 'Alle'}
|
||||
</button>`).join('')}
|
||||
</div>
|
||||
<div id="adm-bew-list">${UI.skeleton(3)}</div>`;
|
||||
|
||||
el.querySelectorAll('.adm-bew-filter').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_statusFilter = btn.dataset.s;
|
||||
_load();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`);
|
||||
const list = el.querySelector('#adm-bew-list');
|
||||
if (!rows.length) {
|
||||
list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.');
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rows.map(r => `
|
||||
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-4)" data-id="${r.id}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-3)">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:700;font-size:var(--text-base)">${_esc(r.name)}
|
||||
${r.username ? `<span style="color:var(--c-text-muted);font-weight:400;font-size:var(--text-sm)">(@${_esc(r.username)})</span>` : ''}
|
||||
</div>
|
||||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:2px">
|
||||
${_esc(r.email)} · @${_esc(r.social_handle||'—')}
|
||||
${r.dog_name ? ` · 🐕 ${_esc(r.dog_name)} (${_esc(r.dog_rasse||'')})` : ''}
|
||||
</div>
|
||||
<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:2px">
|
||||
${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge
|
||||
</div>
|
||||
<div style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3)">
|
||||
${_esc((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);min-width:120px">
|
||||
<button class="btn btn-sm btn-primary adm-bew-view" data-id="${r.id}">Details</button>
|
||||
<select class="form-control adm-bew-status" data-id="${r.id}"
|
||||
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
|
||||
<option value="pending" ${r.status==='pending' ?'selected':''}>⏳ Neu</option>
|
||||
<option value="reviewing" ${r.status==='reviewing'?'selected':''}>🔍 Prüfung</option>
|
||||
<option value="accepted" ${r.status==='accepted' ?'selected':''}>✅ Angenommen</option>
|
||||
<option value="rejected" ${r.status==='rejected' ?'selected':''}>❌ Abgelehnt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
list.querySelectorAll('.adm-bew-status').forEach(sel => {
|
||||
sel.addEventListener('change', async () => {
|
||||
const id = sel.dataset.id;
|
||||
await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value });
|
||||
UI.toast.success('Status aktualisiert.');
|
||||
setTimeout(_load, 500);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.adm-bew-view').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id;
|
||||
const app = await API.get(`/jobs/admin/applications/${id}`);
|
||||
const docsHtml = app.docs?.length
|
||||
? app.docs.map(d => `<a href="/api/jobs/admin/applications/${id}/docs/${d.id}"
|
||||
target="_blank" style="display:block;color:var(--c-primary);font-size:var(--text-sm);margin:4px 0">
|
||||
📎 ${_esc(d.filename)}</a>`).join('')
|
||||
: '<span style="color:var(--c-text-muted);font-size:var(--text-sm)">Keine Anhänge</span>';
|
||||
|
||||
UI.modal.open({
|
||||
title: `Bewerbung — ${_esc(app.name)}`,
|
||||
body: `
|
||||
<div style="display:grid;gap:var(--space-3)">
|
||||
<div><b>E-Mail:</b> ${_esc(app.email)}</div>
|
||||
<div><b>Social:</b> @${_esc(app.social_handle||'—')}</div>
|
||||
${app.dog_name ? `<div><b>Hund:</b> ${_esc(app.dog_name)} (${_esc(app.dog_rasse||'')})</div>` : ''}
|
||||
<div><b>Motivation:</b><br>
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);
|
||||
margin-top:var(--space-1);font-size:var(--text-sm);white-space:pre-wrap">${_esc(app.motivation)}</div>
|
||||
</div>
|
||||
<div><b>Anhänge:</b><br>${docsHtml}</div>
|
||||
<div>
|
||||
<b>Admin-Notiz:</b>
|
||||
<textarea id="adm-bew-note" class="form-control" rows="2" style="margin-top:var(--space-1)"
|
||||
placeholder="Interne Notiz / Nachricht an Bewerber">${_esc(app.admin_note||'')}</textarea>
|
||||
</div>
|
||||
</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
||||
<button class="btn btn-primary" id="adm-bew-save-note">Notiz speichern</button>`,
|
||||
});
|
||||
document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => {
|
||||
const note = document.getElementById('adm-bew-note')?.value || '';
|
||||
await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note });
|
||||
UI.toast.success('Notiz gespeichert.');
|
||||
UI.modal.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
await _load();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
|
|
|
|||
958
backend/static/js/pages/adoption.js
Normal file
958
backend/static/js/pages/adoption.js
Normal file
|
|
@ -0,0 +1,958 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Adoption (Tierheim-Hunde in der Nähe)
|
||||
Seiten-Modul: Hunde aus deutschen Tierheimen finden.
|
||||
============================================================ */
|
||||
|
||||
window.Page_adoption = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MODUL-STATE
|
||||
// ----------------------------------------------------------
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _lat = null;
|
||||
let _lon = null;
|
||||
let _radius = 50;
|
||||
let _rasseFilter = '';
|
||||
let _activeTab = 'hunde';
|
||||
let _data = null; // { animals, shelters, has_petfinder }
|
||||
let _loading = false;
|
||||
let _communityData = null; // [] listings from /adoption/community
|
||||
let _myListings = null; // [] eigene Inserate
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_render();
|
||||
// Standort automatisch versuchen
|
||||
_tryAutoLocate();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// REFRESH
|
||||
// ----------------------------------------------------------
|
||||
async function refresh() {
|
||||
if (_lat && _lon) {
|
||||
await _loadData();
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RENDER — Grundstruktur
|
||||
// ----------------------------------------------------------
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<!-- Filter-Leiste -->
|
||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3);align-items:center">
|
||||
<select id="adp-radius" class="form-control" style="width:auto;min-width:110px">
|
||||
<option value="10">10 km</option>
|
||||
<option value="25">25 km</option>
|
||||
<option value="50" selected>50 km</option>
|
||||
<option value="100">100 km</option>
|
||||
</select>
|
||||
<input id="adp-rasse" class="form-control" type="text"
|
||||
placeholder="Rasse filtern…"
|
||||
style="flex:1;min-width:120px;max-width:220px"
|
||||
value="${_esc(_rasseFilter)}">
|
||||
<button class="btn btn-secondary" id="adp-btn-locate"
|
||||
style="white-space:nowrap">
|
||||
${UI.icon('map-pin')} Mein Standort
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- PLZ-Fallback (anfangs versteckt) -->
|
||||
<div id="adp-plz-row" style="display:none;margin-bottom:var(--space-3)">
|
||||
<div style="display:flex;gap:var(--space-2);align-items:center">
|
||||
<input id="adp-plz" class="form-control" type="text"
|
||||
inputmode="numeric" maxlength="5"
|
||||
placeholder="PLZ eingeben (z.B. 80331)"
|
||||
style="max-width:180px">
|
||||
<button class="btn btn-primary" id="adp-btn-geocode">Suchen</button>
|
||||
</div>
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-1)">
|
||||
Kein Standort verfügbar — PLZ als Ausgangspunkt eingeben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div style="display:flex;gap:var(--space-1);margin-bottom:var(--space-4);
|
||||
border-bottom:1px solid var(--c-border)">
|
||||
<button class="adp-tab adp-tab--active" data-tab="hunde"
|
||||
style="padding:var(--space-2) var(--space-3);background:none;border:none;
|
||||
cursor:pointer;font-weight:600;color:var(--c-primary);
|
||||
border-bottom:2px solid var(--c-primary);font-size:var(--text-sm)">
|
||||
${UI.icon('paw-print')} Hunde
|
||||
</button>
|
||||
<button class="adp-tab" data-tab="tierheime"
|
||||
style="padding:var(--space-2) var(--space-3);background:none;border:none;
|
||||
cursor:pointer;color:var(--c-text-secondary);
|
||||
border-bottom:2px solid transparent;font-size:var(--text-sm)">
|
||||
${UI.icon('house-line')} Tierheime
|
||||
</button>
|
||||
<button class="adp-tab" data-tab="community"
|
||||
style="padding:var(--space-2) var(--space-3);background:none;border:none;
|
||||
cursor:pointer;color:var(--c-text-secondary);
|
||||
border-bottom:2px solid transparent;font-size:var(--text-sm)">
|
||||
${UI.icon('heart')} Weitervermittlung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inhalt -->
|
||||
<div id="adp-content">
|
||||
${UI.skeleton(4)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Events
|
||||
_container.querySelector('#adp-radius')
|
||||
?.addEventListener('change', e => {
|
||||
_radius = parseInt(e.target.value);
|
||||
if (_lat && _lon) _loadData();
|
||||
});
|
||||
|
||||
_container.querySelector('#adp-rasse')
|
||||
?.addEventListener('input', e => {
|
||||
_rasseFilter = e.target.value.trim().toLowerCase();
|
||||
_renderContent();
|
||||
});
|
||||
|
||||
_container.querySelector('#adp-btn-locate')
|
||||
?.addEventListener('click', _locateUser);
|
||||
|
||||
_container.querySelector('#adp-btn-geocode')
|
||||
?.addEventListener('click', _geocodePLZ);
|
||||
|
||||
_container.querySelector('#adp-plz')
|
||||
?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') _geocodePLZ();
|
||||
});
|
||||
|
||||
_container.querySelectorAll('.adp-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_activeTab = btn.dataset.tab;
|
||||
_container.querySelectorAll('.adp-tab').forEach(b => {
|
||||
const isActive = b.dataset.tab === _activeTab;
|
||||
b.style.color = isActive ? 'var(--c-primary)' : 'var(--c-text-secondary)';
|
||||
b.style.fontWeight = isActive ? '600' : 'normal';
|
||||
b.style.borderBottom = isActive ? '2px solid var(--c-primary)' : '2px solid transparent';
|
||||
});
|
||||
_renderContent();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// STANDORT AUTOMATISCH ERMITTELN
|
||||
// ----------------------------------------------------------
|
||||
async function _tryAutoLocate() {
|
||||
try {
|
||||
const pos = await API.getLocation({ timeout: 6000, maximumAge: 300_000 });
|
||||
_lat = pos.lat;
|
||||
_lon = pos.lon;
|
||||
await _loadData();
|
||||
} catch {
|
||||
// Standort nicht verfügbar → PLZ-Eingabe zeigen
|
||||
document.getElementById('adp-plz-row')?.style.setProperty('display', 'flex', 'important');
|
||||
document.getElementById('adp-plz-row').style.display = 'flex';
|
||||
_showNoLocation();
|
||||
}
|
||||
}
|
||||
|
||||
async function _locateUser() {
|
||||
const btn = _container.querySelector('#adp-btn-locate');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const pos = await API.getLocation({ timeout: 10000 });
|
||||
_lat = pos.lat;
|
||||
_lon = pos.lon;
|
||||
document.getElementById('adp-plz-row').style.display = 'none';
|
||||
await _loadData();
|
||||
} catch {
|
||||
UI.toast.error('Standort konnte nicht ermittelt werden. Bitte PLZ eingeben.');
|
||||
document.getElementById('adp-plz-row').style.display = 'flex';
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function _geocodePLZ() {
|
||||
const plz = (_container.querySelector('#adp-plz')?.value || '').trim();
|
||||
if (!plz) return;
|
||||
const btn = _container.querySelector('#adp-btn-geocode');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const geo = await API.get(`/adoption/geocode?plz=${encodeURIComponent(plz)}`);
|
||||
if (geo.lat && geo.lon) {
|
||||
_lat = geo.lat;
|
||||
_lon = geo.lon;
|
||||
await _loadData();
|
||||
} else {
|
||||
UI.toast.error(`PLZ "${plz}" nicht gefunden.`);
|
||||
}
|
||||
} catch {
|
||||
UI.toast.error('Geocoding fehlgeschlagen. Bitte erneut versuchen.');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// DATEN LADEN
|
||||
// ----------------------------------------------------------
|
||||
async function _loadData() {
|
||||
if (_loading || !_lat || !_lon) return;
|
||||
_loading = true;
|
||||
const content = _container.querySelector('#adp-content');
|
||||
if (content) content.innerHTML = UI.skeleton(4);
|
||||
try {
|
||||
_data = await API.get(`/adoption/nearby?lat=${_lat}&lon=${_lon}&radius=${_radius}`);
|
||||
_renderContent();
|
||||
} catch {
|
||||
if (content) content.innerHTML = UI.emptyState({
|
||||
icon: 'warning',
|
||||
title: 'Daten konnten nicht geladen werden',
|
||||
text: 'Bitte versuche es erneut.',
|
||||
});
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function _loadCommunity() {
|
||||
const content = _container.querySelector('#adp-content');
|
||||
if (content) content.innerHTML = UI.skeleton(4);
|
||||
try {
|
||||
const url = _lat && _lon
|
||||
? `/adoption/community?lat=${_lat}&lon=${_lon}`
|
||||
: '/adoption/community';
|
||||
_communityData = await API.get(url);
|
||||
if (_appState?.user) {
|
||||
try {
|
||||
_myListings = await API.get('/adoption/community/my');
|
||||
} catch {
|
||||
_myListings = [];
|
||||
}
|
||||
}
|
||||
_renderCommunity(content);
|
||||
} catch {
|
||||
if (content) content.innerHTML = UI.emptyState({
|
||||
icon: 'warning',
|
||||
title: 'Weitervermittlungs-Inserate konnten nicht geladen werden',
|
||||
text: 'Bitte versuche es erneut.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INHALT RENDERN (je nach Tab)
|
||||
// ----------------------------------------------------------
|
||||
function _renderContent() {
|
||||
const content = _container.querySelector('#adp-content');
|
||||
if (!content) return;
|
||||
|
||||
if (_activeTab === 'community') {
|
||||
_loadCommunity();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_data) { _showNoLocation(); return; }
|
||||
|
||||
if (_activeTab === 'hunde') _renderHunde(content);
|
||||
else _renderTierheime(content);
|
||||
}
|
||||
|
||||
function _showNoLocation() {
|
||||
const content = _container.querySelector('#adp-content');
|
||||
if (!content) return;
|
||||
content.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-8) var(--space-4)">
|
||||
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div>
|
||||
<h3 style="margin-bottom:var(--space-2)">Finde Hunde in deiner Nähe</h3>
|
||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto">
|
||||
Erlaube den Zugriff auf deinen Standort oder gib eine PLZ ein, um Tierheim-Hunde
|
||||
in deiner Umgebung zu finden.
|
||||
</p>
|
||||
<a href="https://www.tierheimhelden.de/hunde/liste"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="btn btn-primary">
|
||||
${UI.icon('arrow-square-out')} Alle Hunde auf Tierheimhelden.de
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB: HUNDE
|
||||
// ------------------------------------------------------------------
|
||||
function _renderHunde(content) {
|
||||
let animals = (_data?.animals || []);
|
||||
|
||||
// Rasse-Filter
|
||||
if (_rasseFilter) {
|
||||
animals = animals.filter(a =>
|
||||
(a.rasse || '').toLowerCase().includes(_rasseFilter) ||
|
||||
(a.name || '').toLowerCase().includes(_rasseFilter)
|
||||
);
|
||||
}
|
||||
|
||||
const hasPetFinder = _data?.has_petfinder;
|
||||
const infoText = hasPetFinder
|
||||
? `${animals.length} Hunde im Umkreis von ${_radius} km (via PetFinder)`
|
||||
: '';
|
||||
|
||||
if (!animals.length) {
|
||||
content.innerHTML = `
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||
${_rasseFilter ? `Keine Hunde gefunden für "<strong>${_esc(_rasseFilter)}</strong>"` : `Keine Hunde im Umkreis von ${_radius} km gefunden.`}
|
||||
</p>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-3);max-width:380px">
|
||||
<a href="https://www.tierheimhelden.de/hunde/liste"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="btn btn-primary">
|
||||
${UI.icon('arrow-square-out')} Hunde auf Tierheimhelden.de suchen
|
||||
</a>
|
||||
<a href="https://www.tierschutz.com/tierheimsuche/"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="btn btn-secondary">
|
||||
${UI.icon('magnifying-glass')} Tierheimsuche auf tierschutz.com
|
||||
</a>
|
||||
</div>
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-4)">
|
||||
Tipp: Schau auch im Tab „Tierheime" nach lokalen Tierheimen direkt.
|
||||
</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
${infoText ? `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">${infoText}</p>` : ''}
|
||||
<div class="adp-grid"
|
||||
style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:var(--space-3)">
|
||||
${animals.map(a => _animalCard(a)).join('')}
|
||||
</div>
|
||||
<div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
||||
Mehr Hunde finden:
|
||||
</p>
|
||||
<a href="https://www.tierheimhelden.de/hunde/liste"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="btn btn-secondary" style="font-size:var(--text-sm)">
|
||||
${UI.icon('arrow-square-out')} Tierheimhelden.de — alle Hunde
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Klick-Events
|
||||
content.querySelectorAll('[data-adp-url]').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
window.open(card.dataset.adpUrl, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _animalCard(a) {
|
||||
const foto = a.foto_url
|
||||
? `<img src="${_esc(a.foto_url)}" alt="${_esc(a.name)}"
|
||||
style="width:100%;height:100%;object-fit:cover"
|
||||
onerror="this.parentElement.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem">🐶</div>'">`
|
||||
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐶</div>';
|
||||
|
||||
const distTxt = a.distanz_km != null ? `${a.distanz_km} km` : '';
|
||||
const alterTxt = a.alter_jahre != null ? `${_formatAlter(a.alter_jahre)}` : '';
|
||||
const rasseTxt = a.rasse || '';
|
||||
const tierheim = a.tierheim || '';
|
||||
|
||||
return `
|
||||
<div data-adp-url="${_esc(a.adoptions_url)}"
|
||||
style="border-radius:var(--radius-md);overflow:hidden;
|
||||
background:var(--c-surface-2);cursor:pointer;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,0.08);
|
||||
transition:transform .15s,box-shadow .15s"
|
||||
onmouseenter="this.style.transform='translateY(-2px)';this.style.boxShadow='0 4px 12px rgba(0,0,0,0.12)'"
|
||||
onmouseleave="this.style.transform='';this.style.boxShadow='0 1px 4px rgba(0,0,0,0.08)'">
|
||||
<div style="height:120px;overflow:hidden;background:var(--c-surface-3)">
|
||||
${foto}
|
||||
</div>
|
||||
<div style="padding:var(--space-2) var(--space-2) var(--space-3)">
|
||||
<div style="font-weight:600;font-size:var(--text-sm);
|
||||
margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
${_esc(a.name)}
|
||||
</div>
|
||||
${rasseTxt ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
${_esc(rasseTxt)}
|
||||
</div>` : ''}
|
||||
<div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1)">
|
||||
${alterTxt ? `<span style="font-size:10px;background:var(--c-surface-3);
|
||||
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
|
||||
${_esc(alterTxt)}
|
||||
</span>` : ''}
|
||||
${a.geschlecht ? `<span style="font-size:10px;background:var(--c-surface-3);
|
||||
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
|
||||
${a.geschlecht === 'männlich' ? '♂' : '♀'}
|
||||
</span>` : ''}
|
||||
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
|
||||
border-radius:999px;padding:1px 6px;color:var(--c-primary)">
|
||||
${_esc(distTxt)}
|
||||
</span>` : ''}
|
||||
</div>
|
||||
${tierheim ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:var(--space-1);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${_esc(tierheim)}">
|
||||
${UI.icon('house-line')} ${_esc(tierheim)}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB: TIERHEIME
|
||||
// ------------------------------------------------------------------
|
||||
function _renderTierheime(content) {
|
||||
const shelters = _data?.shelters || [];
|
||||
|
||||
if (!shelters.length) {
|
||||
content.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-6) var(--space-4)">
|
||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||
Keine Tierheime im Umkreis von ${_radius} km gefunden.
|
||||
</p>
|
||||
<a href="https://www.tierheimhelden.de"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="btn btn-primary">
|
||||
${UI.icon('arrow-square-out')} Tierheimhelden.de
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
|
||||
${shelters.length} Tierheim${shelters.length !== 1 ? 'e' : ''} im Umkreis von ${_radius} km
|
||||
</p>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
${shelters.map(s => _shelterRow(s)).join('')}
|
||||
</div>
|
||||
<div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
||||
Noch mehr Tierheime:
|
||||
</p>
|
||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||||
<a href="https://www.tierheimhelden.de"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="btn btn-secondary btn-sm" style="font-size:var(--text-sm)">
|
||||
${UI.icon('arrow-square-out')} Tierheimhelden.de
|
||||
</a>
|
||||
<a href="https://www.tierschutz.com/tierheimsuche/"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="btn btn-secondary btn-sm" style="font-size:var(--text-sm)">
|
||||
${UI.icon('magnifying-glass')} tierschutz.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _shelterRow(s) {
|
||||
return `
|
||||
<a href="${_esc(s.url)}" target="_blank" rel="noopener noreferrer"
|
||||
style="display:flex;align-items:center;gap:var(--space-3);
|
||||
padding:var(--space-3);border-radius:var(--radius-md);
|
||||
background:var(--c-surface-2);text-decoration:none;color:inherit;
|
||||
border:1px solid var(--c-border);
|
||||
transition:background .15s"
|
||||
onmouseenter="this.style.background='var(--c-surface-3)'"
|
||||
onmouseleave="this.style.background='var(--c-surface-2)'">
|
||||
<div style="width:40px;height:40px;border-radius:50%;
|
||||
background:var(--c-primary-light,#ede9fe);flex-shrink:0;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:1.2rem">
|
||||
🏠
|
||||
</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:600;font-size:var(--text-sm);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
${_esc(s.name)}
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
${_esc(s.plz)} ${_esc(s.stadt)}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:2px;flex-shrink:0">
|
||||
<span style="font-size:var(--text-xs);font-weight:600;
|
||||
color:var(--c-primary);background:var(--c-primary-light,#ede9fe);
|
||||
border-radius:999px;padding:2px 8px">
|
||||
${s.distanz_km} km
|
||||
</span>
|
||||
<span style="font-size:10px;color:var(--c-text-muted)">Hunde ansehen ${UI.icon('arrow-right')}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB: WEITERVERMITTLUNG (Community)
|
||||
// ------------------------------------------------------------------
|
||||
function _renderCommunity(content) {
|
||||
if (!content) return;
|
||||
|
||||
const listings = _communityData || [];
|
||||
const isLoggedIn = !!_appState?.user;
|
||||
|
||||
const fabHtml = isLoggedIn ? `
|
||||
<button id="adp-fab-create"
|
||||
style="position:fixed;bottom:calc(var(--nav-height,64px) + var(--space-4));right:var(--space-4);
|
||||
z-index:100;width:56px;height:56px;border-radius:50%;
|
||||
background:var(--c-primary);color:#fff;border:none;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,0.2);
|
||||
display:flex;align-items:center;justify-content:center;font-size:1.5rem"
|
||||
title="Hund zur Vermittlung anbieten"
|
||||
aria-label="Hund zur Vermittlung anbieten">
|
||||
${UI.icon('plus')}
|
||||
</button>
|
||||
` : '';
|
||||
|
||||
if (!listings.length) {
|
||||
content.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-8) var(--space-4)">
|
||||
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div>
|
||||
<h3 style="margin-bottom:var(--space-2)">Noch keine Hunde zur Weitervermittlung</h3>
|
||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto">
|
||||
Hier können Halter Hunde privat zur Weitervermittlung anbieten —
|
||||
zum Beispiel bei Umzug, Krankheit oder Allergie.
|
||||
</p>
|
||||
${isLoggedIn ? `
|
||||
<button class="btn btn-primary" id="adp-empty-create">
|
||||
${UI.icon('plus')} Hund zur Vermittlung anbieten
|
||||
</button>
|
||||
` : `
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
Bitte anmelden, um ein Inserat zu erstellen.
|
||||
</p>
|
||||
`}
|
||||
</div>
|
||||
${fabHtml}
|
||||
`;
|
||||
content.querySelector('#adp-empty-create')?.addEventListener('click', _openCreateModal);
|
||||
content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Eigene Inserate trennen
|
||||
const myIds = new Set((_myListings || []).map(l => l.id));
|
||||
|
||||
content.innerHTML = `
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
|
||||
${listings.length} Inserat${listings.length !== 1 ? 'e' : ''} zur Weitervermittlung
|
||||
</p>
|
||||
<div class="adp-grid"
|
||||
style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:var(--space-3)">
|
||||
${listings.map(l => _communityCard(l)).join('')}
|
||||
</div>
|
||||
|
||||
${isLoggedIn && _myListings && _myListings.length ? `
|
||||
<div id="adp-my-listings" style="margin-top:var(--space-6);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
|
||||
<h4 style="margin-bottom:var(--space-3)">Meine Inserate</h4>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
${_myListings.map(l => _myListingRow(l)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${fabHtml}
|
||||
`;
|
||||
|
||||
// Interest-Button Events
|
||||
content.querySelectorAll('[data-adp-interest]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = btn.dataset.adpInterest;
|
||||
const interested = btn.dataset.adpInterested === 'true';
|
||||
_handleInterest(id, interested, btn);
|
||||
});
|
||||
});
|
||||
|
||||
// FAB
|
||||
content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal);
|
||||
|
||||
// Meine Inserate: Status-Dropdown + Löschen
|
||||
content.querySelectorAll('[data-adp-status-change]').forEach(sel => {
|
||||
sel.addEventListener('change', async () => {
|
||||
const id = sel.dataset.adpStatusChange;
|
||||
try {
|
||||
await API.patch(`/adoption/community/${id}`, { status: sel.value });
|
||||
UI.toast.success('Status aktualisiert.');
|
||||
_loadCommunity();
|
||||
} catch {
|
||||
UI.toast.error('Status konnte nicht aktualisiert werden.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
content.querySelectorAll('[data-adp-delete]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!window.confirm('Inserat wirklich löschen?')) return;
|
||||
try {
|
||||
await API.del(`/adoption/community/${btn.dataset.adpDelete}`);
|
||||
UI.toast.success('Inserat gelöscht.');
|
||||
_communityData = null;
|
||||
_myListings = null;
|
||||
_loadCommunity();
|
||||
} catch {
|
||||
UI.toast.error('Löschen fehlgeschlagen.');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _communityCard(l) {
|
||||
const foto = l.foto_url
|
||||
? `<img src="${_esc(l.foto_url)}" alt="${_esc(l.name)}"
|
||||
style="width:100%;height:100%;object-fit:cover"
|
||||
onerror="this.parentElement.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>'">`
|
||||
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>';
|
||||
|
||||
const isActive = !l.status || l.status === 'active';
|
||||
const statusLabel = l.status === 'reserved' ? 'Reserviert'
|
||||
: l.status === 'adopted' ? 'Vermittelt'
|
||||
: '';
|
||||
|
||||
const alterLabel = l.alter_kategorie === 'welpe' ? 'Welpe <6Mo'
|
||||
: l.alter_kategorie === 'jung' ? 'Jung 6Mo–2J'
|
||||
: l.alter_kategorie === 'adult' ? 'Adult 2–8J'
|
||||
: l.alter_kategorie === 'senior' ? 'Senior >8J'
|
||||
: '';
|
||||
|
||||
const genderIcon = l.geschlecht === 'maennlich' ? '♂'
|
||||
: l.geschlecht === 'weiblich' ? '♀'
|
||||
: '';
|
||||
|
||||
const distTxt = l.distanz_km != null ? `${l.distanz_km} km` : '';
|
||||
const ort = [l.plz, l.ort].filter(Boolean).join(' ');
|
||||
|
||||
const interestBtn = l.user_interested
|
||||
? `<button class="btn btn-secondary btn-sm" style="width:100%;font-size:var(--text-xs)"
|
||||
data-adp-interest="${_esc(l.id)}" data-adp-interested="true">
|
||||
✓ Bereits gemeldet
|
||||
</button>`
|
||||
: `<button class="btn btn-primary btn-sm" style="width:100%;font-size:var(--text-xs)"
|
||||
data-adp-interest="${_esc(l.id)}" data-adp-interested="false"
|
||||
${!isActive ? 'disabled' : ''}>
|
||||
Interesse bekunden
|
||||
</button>`;
|
||||
|
||||
return `
|
||||
<div style="border-radius:var(--radius-md);overflow:hidden;
|
||||
background:var(--c-bg-card,var(--c-surface-2));
|
||||
box-shadow:0 1px 4px rgba(0,0,0,0.08);
|
||||
display:flex;flex-direction:column;position:relative">
|
||||
<!-- Foto -->
|
||||
<div style="height:140px;overflow:hidden;background:var(--c-surface-3);position:relative">
|
||||
${foto}
|
||||
${!isActive ? `
|
||||
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.45);
|
||||
display:flex;align-items:center;justify-content:center">
|
||||
<span style="color:#fff;font-weight:700;font-size:var(--text-sm);
|
||||
background:rgba(0,0,0,0.6);padding:4px 12px;border-radius:999px">
|
||||
${_esc(statusLabel)}
|
||||
</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<!-- Body -->
|
||||
<div style="padding:var(--space-2) var(--space-2) var(--space-3);flex:1;display:flex;flex-direction:column;gap:var(--space-1)">
|
||||
<div style="font-weight:600;font-size:var(--text-sm);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
${_esc(l.name)}
|
||||
</div>
|
||||
${l.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
${_esc(l.rasse)}
|
||||
</div>` : ''}
|
||||
<!-- Badges -->
|
||||
<div style="display:flex;gap:4px;flex-wrap:wrap">
|
||||
${alterLabel ? `<span style="font-size:10px;background:var(--c-surface-3);
|
||||
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
|
||||
${_esc(alterLabel)}
|
||||
</span>` : ''}
|
||||
${genderIcon ? `<span style="font-size:10px;background:var(--c-surface-3);
|
||||
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
|
||||
${genderIcon}
|
||||
</span>` : ''}
|
||||
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
|
||||
border-radius:999px;padding:1px 6px;color:var(--c-primary)">
|
||||
${_esc(distTxt)}
|
||||
</span>` : ''}
|
||||
</div>
|
||||
${ort ? `<div style="font-size:10px;color:var(--c-text-muted)">${_esc(ort)}</div>` : ''}
|
||||
${l.beschreibung ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
overflow:hidden;display:-webkit-box;
|
||||
-webkit-line-clamp:2;-webkit-box-orient:vertical">
|
||||
${_esc(l.beschreibung)}
|
||||
</div>` : ''}
|
||||
${l.interesse_count ? `<div style="font-size:10px;color:var(--c-text-muted)">
|
||||
❤️ ${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''}
|
||||
</div>` : ''}
|
||||
<div style="margin-top:auto;padding-top:var(--space-1)">
|
||||
${interestBtn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _myListingRow(l) {
|
||||
const statusOptions = [
|
||||
{ value: 'active', label: 'Aktiv' },
|
||||
{ value: 'reserved', label: 'Reserviert' },
|
||||
{ value: 'adopted', label: 'Vermittelt' },
|
||||
];
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);
|
||||
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||
background:var(--c-surface-2);border:1px solid var(--c-border)">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:600;font-size:var(--text-sm);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
${_esc(l.name)}
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<select class="form-control" style="width:auto;font-size:var(--text-xs)"
|
||||
data-adp-status-change="${_esc(l.id)}">
|
||||
${statusOptions.map(o => `
|
||||
<option value="${o.value}" ${l.status === o.value ? 'selected' : ''}>${o.label}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
<button class="btn btn-danger btn-sm" style="font-size:var(--text-xs);white-space:nowrap"
|
||||
data-adp-delete="${_esc(l.id)}">
|
||||
${UI.icon('trash')} Löschen
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// INTERESSE BEKUNDEN / ZURÜCKZIEHEN
|
||||
// ------------------------------------------------------------------
|
||||
async function _handleInterest(id, isInterested, btn) {
|
||||
if (!_appState?.user) {
|
||||
UI.toast.error('Bitte anmelden um Interesse zu bekunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInterested) {
|
||||
// Interesse zurückziehen
|
||||
try {
|
||||
btn.disabled = true;
|
||||
await API.del(`/adoption/community/${id}/interest`);
|
||||
UI.toast.success('Interesse zurückgezogen.');
|
||||
_communityData = null;
|
||||
_myListings = null;
|
||||
_loadCommunity();
|
||||
} catch {
|
||||
UI.toast.error('Fehler beim Zurückziehen des Interesses.');
|
||||
btn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Interesse bekunden — Modal mit optionaler Nachricht
|
||||
const body = `
|
||||
<form id="adp-interest-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||||
Du kannst optional eine Nachricht an den Anbieter schicken.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nachricht (optional)</label>
|
||||
<textarea class="form-control" name="nachricht" rows="3"
|
||||
placeholder="Stell dich kurz vor und erzähle, warum dieser Hund zu dir passt…"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
const footer = `
|
||||
<div style="display:flex;gap:var(--space-2);width:100%">
|
||||
<button type="submit" form="adp-interest-form" class="btn btn-primary flex-1" id="adp-interest-submit">
|
||||
${UI.icon('heart')} Interesse bekunden
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: 'Interesse bekunden', body, footer });
|
||||
|
||||
document.getElementById('adp-interest-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const submitBtn = document.getElementById('adp-interest-submit');
|
||||
const fd = new FormData(e.target);
|
||||
const payload = { nachricht: fd.get('nachricht') || null };
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
|
||||
try {
|
||||
await API.post(`/adoption/community/${id}/interest`, payload);
|
||||
UI.modal.close();
|
||||
UI.toast.success('Interesse gemeldet!');
|
||||
_communityData = null;
|
||||
_myListings = null;
|
||||
_loadCommunity();
|
||||
} catch {
|
||||
UI.toast.error('Fehler beim Melden des Interesses.');
|
||||
if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = `${UI.icon('heart')} Interesse bekunden`; }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// INSERAT ERSTELLEN — Modal
|
||||
// ------------------------------------------------------------------
|
||||
function _openCreateModal() {
|
||||
if (!_appState?.user) {
|
||||
UI.toast.error('Bitte anmelden um ein Inserat zu erstellen.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = `
|
||||
<form id="adp-create-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name <span style="color:var(--c-danger)">*</span></label>
|
||||
<input class="form-control" name="name" required placeholder="z.B. Bello">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Rasse (optional)</label>
|
||||
<input class="form-control" name="rasse" placeholder="z.B. Labrador Mischling">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alter</label>
|
||||
<select class="form-control" name="alter_kategorie">
|
||||
<option value="">Unbekannt</option>
|
||||
<option value="welpe">Welpe <6Mo</option>
|
||||
<option value="jung">Jung 6Mo–2J</option>
|
||||
<option value="adult">Adult 2–8J</option>
|
||||
<option value="senior">Senior >8J</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Geschlecht</label>
|
||||
<select class="form-control" name="geschlecht">
|
||||
<option value="">Unbekannt</option>
|
||||
<option value="maennlich">Männlich</option>
|
||||
<option value="weiblich">Weiblich</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">PLZ</label>
|
||||
<input class="form-control" name="plz" inputmode="numeric" maxlength="5"
|
||||
placeholder="z.B. 80331" value="${_esc(_lat ? '' : '')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Ort</label>
|
||||
<input class="form-control" name="ort" placeholder="z.B. München">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Beschreibung <span style="color:var(--c-danger)">*</span></label>
|
||||
<textarea class="form-control" name="beschreibung" rows="4" required minlength="80"
|
||||
placeholder="Erzähle, warum du deinen Hund abgeben musst, und was ihn besonders macht…"></textarea>
|
||||
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px">Mindestens 80 Zeichen</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Hintergrund (optional)</label>
|
||||
<textarea class="form-control" name="hintergrund" rows="2"
|
||||
placeholder="Warum suchst du ein neues Zuhause? (Krankheit, Umzug, Allergie…)"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Foto (optional)</label>
|
||||
<input class="form-control" type="file" name="foto" accept="image/*" id="adp-create-foto">
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const footer = `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button type="submit" form="adp-create-form" class="btn btn-primary" style="width:100%" id="adp-create-submit">
|
||||
${UI.icon('plus')} Inserat erstellen
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: 'Hund zur Vermittlung anbieten', body, footer });
|
||||
|
||||
document.getElementById('adp-create-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const submitBtn = document.getElementById('adp-create-submit');
|
||||
const fd = new FormData(e.target);
|
||||
|
||||
// Mindestlänge Beschreibung manuell prüfen (minlength gilt nur für text)
|
||||
const beschreibung = (fd.get('beschreibung') || '').trim();
|
||||
if (beschreibung.length < 80) {
|
||||
UI.toast.error('Beschreibung muss mindestens 80 Zeichen lang sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
// FormData für multipart aufbauen
|
||||
const postData = new FormData();
|
||||
postData.append('name', fd.get('name') || '');
|
||||
postData.append('rasse', fd.get('rasse') || '');
|
||||
postData.append('alter_kategorie', fd.get('alter_kategorie') || '');
|
||||
postData.append('geschlecht', fd.get('geschlecht') || '');
|
||||
postData.append('plz', fd.get('plz') || '');
|
||||
postData.append('ort', fd.get('ort') || '');
|
||||
postData.append('beschreibung', beschreibung);
|
||||
postData.append('hintergrund', fd.get('hintergrund') || '');
|
||||
if (_lat) postData.append('lat', _lat);
|
||||
if (_lon) postData.append('lon', _lon);
|
||||
const fotoFile = document.getElementById('adp-create-foto')?.files?.[0];
|
||||
if (fotoFile) postData.append('foto', fotoFile);
|
||||
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
|
||||
try {
|
||||
await API.upload('/adoption/community', postData);
|
||||
UI.modal.close();
|
||||
UI.toast.success('Inserat erstellt!');
|
||||
_communityData = null;
|
||||
_myListings = null;
|
||||
_loadCommunity();
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Inserat konnte nicht erstellt werden.');
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = `${UI.icon('plus')} Inserat erstellen`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HILFSFUNKTIONEN
|
||||
// ----------------------------------------------------------
|
||||
function _formatAlter(jahre) {
|
||||
if (jahre < 0.5) return 'Welpe';
|
||||
if (jahre < 1) return 'Jungtier';
|
||||
if (jahre < 2) return `${Math.round(jahre)} Jahr`;
|
||||
if (jahre < 10) return `${Math.round(jahre)} Jahre`;
|
||||
return 'Senior';
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC API
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
|
||||
})();
|
||||
|
|
@ -187,16 +187,14 @@ window.Page_dog_profile = (() => {
|
|||
${!dog.is_guest ? `<button class="btn btn-primary w-full" id="dp-edit-btn">
|
||||
Profil bearbeiten
|
||||
</button>` : ''}
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<button class="btn btn-secondary" style="flex:1" id="dp-ausweis-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
|
||||
Ausweis
|
||||
</button>
|
||||
${!dog.is_guest ? `<button class="btn btn-secondary" style="flex:1" id="dp-share-btn">
|
||||
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-share-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
|
||||
Teilen
|
||||
</button>` : ''}
|
||||
</div>
|
||||
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-passport-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#notebook"></use></svg>
|
||||
Hundepass
|
||||
</button>` : ''}
|
||||
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
|
||||
+ Weiteren Hund anlegen
|
||||
</button>` : ''}
|
||||
|
|
@ -209,7 +207,8 @@ window.Page_dog_profile = (() => {
|
|||
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
|
||||
<div style="font-weight:600">Sitter-Zugang</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
Gib einem Freund temporären Schreibzugang für diesen Hund
|
||||
Gib einem Freund temporären Schreibzugang für diesen Hund.
|
||||
Deine bestehenden Daten und Medien bleiben unsichtbar und privat — der Sitter kann nur neue Einträge anlegen.
|
||||
</div>
|
||||
</div>
|
||||
<div id="dp-sitting-access" style="padding:var(--space-4)">Lade…</div>
|
||||
|
|
@ -257,14 +256,14 @@ window.Page_dog_profile = (() => {
|
|||
_showChipEdit(dog);
|
||||
});
|
||||
|
||||
document.getElementById('dp-ausweis-btn')?.addEventListener('click', () => {
|
||||
_showAusweisModal(dog.id);
|
||||
});
|
||||
|
||||
document.getElementById('dp-share-btn')?.addEventListener('click', () => {
|
||||
_showShareModal(dog);
|
||||
});
|
||||
|
||||
document.getElementById('dp-passport-btn')?.addEventListener('click', () => {
|
||||
_showPassportModal(dog);
|
||||
});
|
||||
|
||||
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
|
||||
}
|
||||
|
||||
|
|
@ -745,13 +744,7 @@ window.Page_dog_profile = (() => {
|
|||
// AUSWEIS
|
||||
// ----------------------------------------------------------
|
||||
function _showAusweisModal(dogId) {
|
||||
UI.modal.open({
|
||||
title: 'Heimtierausweis',
|
||||
body: `<iframe src="/ausweis/${dogId}" class="ausweis-frame" title="Heimtierausweis"></iframe>`,
|
||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
||||
<a href="/ausweis/${dogId}" target="_blank" class="btn btn-ghost">${UI.icon('printer')} Drucken</a>`,
|
||||
size: 'fullscreen',
|
||||
});
|
||||
window.open(`/ausweis/${dogId}`, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -996,7 +989,7 @@ window.Page_dog_profile = (() => {
|
|||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Foto</label>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap">
|
||||
<img id="dp-form-preview"
|
||||
src="${dog?.foto_url || ''}"
|
||||
style="width:64px;height:64px;border-radius:50%;object-fit:cover;
|
||||
|
|
@ -1007,6 +1000,16 @@ window.Page_dog_profile = (() => {
|
|||
<input type="file" name="foto" accept="image/*" style="display:none"
|
||||
id="dp-form-foto">
|
||||
</label>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn"
|
||||
style="margin:0">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||||
Rasse erkennen
|
||||
</button>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp"
|
||||
id="dp-rasse-foto-input" style="display:none">
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
|
||||
Foto hochladen um die Rasse per KI zu erkennen
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1086,6 +1089,9 @@ window.Page_dog_profile = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// Rassen-Erkennung per KI
|
||||
_bindRasseErkennung();
|
||||
|
||||
document.getElementById('dp-form-cancel')
|
||||
?.addEventListener('click', UI.modal.close);
|
||||
|
||||
|
|
@ -1171,6 +1177,152 @@ window.Page_dog_profile = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RASSEN-ERKENNUNG PER KI (Formular)
|
||||
// ----------------------------------------------------------
|
||||
function _bindRasseErkennung() {
|
||||
const btn = document.getElementById('dp-rasse-erkennen-btn');
|
||||
const fileInput = document.getElementById('dp-rasse-foto-input');
|
||||
if (!btn || !fileInput) return;
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', async () => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
UI.toast.error('Bild zu groß (max. 5 MB).');
|
||||
return;
|
||||
}
|
||||
|
||||
const origLabel = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> KI analysiert…`;
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const token = localStorage.getItem('by_token');
|
||||
const resp = await fetch('/api/ki/rasse-erkennung', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.');
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origLabel;
|
||||
_showRasseErgebnis(data);
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origLabel;
|
||||
UI.toast.error(e.message || 'Fehler bei der Rassen-Erkennung.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _showRasseErgebnis(data) {
|
||||
if (!data.ist_hund) {
|
||||
UI.modal.open({
|
||||
title: 'Kein Hund erkannt',
|
||||
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
|
||||
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div>
|
||||
<p style="color:var(--c-text-secondary)">
|
||||
Auf diesem Foto konnte kein Hund erkannt werden.<br>
|
||||
Bitte lade ein deutlicheres Foto hoch.
|
||||
</p>
|
||||
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${_esc(data.hinweis)}</p>` : ''}
|
||||
</div>`,
|
||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rassen = data.rassen || [];
|
||||
const cardsHtml = rassen.map((r, i) => {
|
||||
const isTop = i === 0;
|
||||
return `
|
||||
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${_esc(r.name)}</div>
|
||||
<span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
|
||||
</div>
|
||||
<div class="rasse-result-bar-wrap">
|
||||
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
|
||||
style="width:${r.sicherheit}%"></div>
|
||||
</div>
|
||||
${r.beschreibung ? `<div class="rasse-result-desc">${_esc(r.beschreibung)}</div>` : ''}
|
||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);flex-wrap:wrap">
|
||||
${isTop ? `<button class="btn btn-primary btn-sm" data-action="uebernehmen"
|
||||
data-rasse="${_esc(r.name)}" style="flex:1">
|
||||
Rasse übernehmen
|
||||
</button>` : `<button class="btn btn-secondary btn-sm" data-action="uebernehmen"
|
||||
data-rasse="${_esc(r.name)}" style="flex:1">
|
||||
Diese wählen
|
||||
</button>`}
|
||||
${r.wiki_slug ? `<button class="btn btn-ghost btn-sm" data-action="wiki"
|
||||
data-slug="${_esc(r.wiki_slug)}">
|
||||
Im Wiki
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
UI.modal.open({
|
||||
title: 'Erkannte Rasse',
|
||||
body: `
|
||||
<div style="padding-bottom:var(--space-2)">
|
||||
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||
padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm);
|
||||
color:var(--c-text-secondary)">ℹ️ ${_esc(data.hinweis)}</div>` : ''}
|
||||
${cardsHtml}
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
|
||||
text-align:center">
|
||||
Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
footer: `<button class="btn btn-secondary" id="dp-rasse-modal-schliessen">Schließen</button>`,
|
||||
});
|
||||
|
||||
document.getElementById('dp-rasse-modal-schliessen')
|
||||
?.addEventListener('click', UI.modal.close);
|
||||
|
||||
document.querySelectorAll('[data-action="uebernehmen"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const rasse = btn.dataset.rasse;
|
||||
const rasseInput = document.getElementById('dp-rasse-input');
|
||||
const rasseIdInput = document.getElementById('dp-rasse-id');
|
||||
const matchBadge = document.getElementById('dp-rasse-match');
|
||||
if (rasseInput) {
|
||||
rasseInput.value = rasse;
|
||||
rasseInput.dispatchEvent(new Event('input'));
|
||||
}
|
||||
UI.modal.close();
|
||||
UI.toast.success(`Rasse "${rasse}" übernommen.`);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-action="wiki"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
UI.modal.close();
|
||||
App.navigate('wiki');
|
||||
setTimeout(() => {
|
||||
if (window.Page_wiki && typeof Page_wiki._openBreedDetail === 'function') {
|
||||
Page_wiki._openBreedDetail(btn.dataset.slug);
|
||||
}
|
||||
}, 400);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HELPER
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -1196,6 +1348,444 @@ window.Page_dog_profile = (() => {
|
|||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HUNDEPASS
|
||||
// ----------------------------------------------------------
|
||||
async function _showPassportModal(dog) {
|
||||
UI.modal.open({
|
||||
title: `Hundepass — ${_esc(dog.name)}`,
|
||||
body: `<div id="pp-body" style="min-height:200px">
|
||||
<div style="text-align:center;padding:var(--space-6)">
|
||||
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#spinner-gap"></use>
|
||||
</svg>
|
||||
</div>
|
||||
</div>`,
|
||||
footer: `
|
||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;justify-content:flex-end">
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
||||
<a class="btn btn-secondary" href="/ausweis/${dog.id}" target="_blank" rel="noopener">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
|
||||
Ausweis öffnen
|
||||
</a>
|
||||
<button class="btn btn-secondary" id="pp-share-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
|
||||
Link teilen
|
||||
</button>
|
||||
<a class="btn btn-primary" id="pp-pdf-btn"
|
||||
href="/api/passport/${dog.id}/pdf" target="_blank" download>
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-pdf"></use></svg>
|
||||
PDF
|
||||
</a>
|
||||
</div>`,
|
||||
size: 'large',
|
||||
});
|
||||
|
||||
document.getElementById('pp-share-btn')?.addEventListener('click', () => {
|
||||
_createPassportShare(dog);
|
||||
});
|
||||
|
||||
await _loadPassportBody(dog);
|
||||
}
|
||||
|
||||
async function _loadPassportBody(dog) {
|
||||
const wrap = document.getElementById('pp-body');
|
||||
if (!wrap) return;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await API.get(`/passport/${dog.id}`);
|
||||
} catch (e) {
|
||||
wrap.innerHTML = `<p style="color:var(--c-danger)">Fehler beim Laden: ${_esc(e.message)}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const _fmt = d => {
|
||||
if (!d) return '–';
|
||||
try {
|
||||
const p = d.substring(0, 10).split('-');
|
||||
return `${p[2]}.${p[1]}.${p[0]}`;
|
||||
} catch { return d; }
|
||||
};
|
||||
|
||||
const meta = data.meta || {};
|
||||
const vaccs = data.vaccinations || [];
|
||||
const meds = data.medications || [];
|
||||
|
||||
wrap.innerHTML = `
|
||||
<!-- Meta: Blutgruppe, Allergien, Besonderheiten -->
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;
|
||||
margin-bottom:var(--space-3)">
|
||||
<span style="font-weight:700;font-size:var(--text-sm)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#info"></use></svg>
|
||||
Gesundheits-Info
|
||||
</span>
|
||||
<button class="btn btn-secondary btn-sm" id="pp-meta-edit-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Blutgruppe</div>
|
||||
<div id="pp-meta-blutgruppe" style="font-size:var(--text-sm);font-weight:500">
|
||||
${_esc(meta.blutgruppe) || '<span style="color:var(--c-text-muted)">nicht eingetragen</span>'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Allergien</div>
|
||||
<div id="pp-meta-allergien" style="font-size:var(--text-sm)">
|
||||
${_esc(meta.allergien) || '<span style="color:var(--c-text-muted)">keine</span>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${meta.besonderheiten ? `
|
||||
<div style="margin-top:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Besonderheiten</div>
|
||||
<div id="pp-meta-besonderheiten" style="font-size:var(--text-sm)">
|
||||
${_esc(meta.besonderheiten)}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Impfungen -->
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;
|
||||
margin-bottom:var(--space-3)">
|
||||
<span style="font-weight:700;font-size:var(--text-sm)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
|
||||
Impfungen
|
||||
</span>
|
||||
<button class="btn btn-primary btn-sm" id="pp-vacc-add-btn">+ Eintragen</button>
|
||||
</div>
|
||||
<div id="pp-vacc-list">
|
||||
${vaccs.length === 0
|
||||
? `<div style="text-align:center;padding:var(--space-4) var(--space-2);color:var(--c-text-muted)">
|
||||
<svg class="ph-icon" style="width:32px;height:32px;margin-bottom:var(--space-2);opacity:.4" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
|
||||
<p style="font-size:var(--text-sm);margin:0">Noch keine Impfungen eingetragen.<br>Klicke auf „+ Eintragen" um loszulegen.</p>
|
||||
</div>`
|
||||
: vaccs.map(v => `
|
||||
<div class="pp-vacc-row" data-id="${v.id}"
|
||||
style="display:flex;align-items:flex-start;gap:var(--space-3);
|
||||
padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.krankheit)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||||
Gegeben: ${_fmt(v.datum)}
|
||||
${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''}
|
||||
${v.tierarzt ? ` · ${_esc(v.tierarzt)}` : ''}
|
||||
${v.charge_nr ? ` · Charge: ${_esc(v.charge_nr)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}"
|
||||
style="color:var(--c-danger);flex-shrink:0;padding:0">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||||
</button>
|
||||
</div>`).join('')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Medikamente -->
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;
|
||||
margin-bottom:var(--space-3)">
|
||||
<span style="font-weight:700;font-size:var(--text-sm)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pill"></use></svg>
|
||||
Medikamente
|
||||
</span>
|
||||
<button class="btn btn-primary btn-sm" id="pp-med-add-btn">+ Eintragen</button>
|
||||
</div>
|
||||
<div id="pp-med-list">
|
||||
${meds.length === 0
|
||||
? `<div style="text-align:center;padding:var(--space-4) var(--space-2);color:var(--c-text-muted)">
|
||||
<svg class="ph-icon" style="width:32px;height:32px;margin-bottom:var(--space-2);opacity:.4" aria-hidden="true"><use href="/icons/phosphor.svg#pill"></use></svg>
|
||||
<p style="font-size:var(--text-sm);margin:0">Noch keine Medikamente eingetragen.<br>Klicke auf „+ Eintragen" um loszulegen.</p>
|
||||
</div>`
|
||||
: meds.map(m => `
|
||||
<div class="pp-med-row" data-id="${m.id}"
|
||||
style="display:flex;align-items:flex-start;gap:var(--space-3);
|
||||
padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(m.name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||||
${m.dosierung ? `${_esc(m.dosierung)} · ` : ''}
|
||||
${m.von ? `Von ${_fmt(m.von)}` : ''}
|
||||
${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''}
|
||||
${m.notiz ? ` · ${_esc(m.notiz)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-link btn-sm pp-med-del" data-id="${m.id}"
|
||||
style="color:var(--c-danger);flex-shrink:0;padding:0">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||||
</button>
|
||||
</div>`).join('')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Meta bearbeiten
|
||||
document.getElementById('pp-meta-edit-btn')?.addEventListener('click', () => {
|
||||
_editPassportMeta(dog, meta, () => _loadPassportBody(dog));
|
||||
});
|
||||
|
||||
// Impfung hinzufügen
|
||||
document.getElementById('pp-vacc-add-btn')?.addEventListener('click', () => {
|
||||
_addVaccination(dog, () => _loadPassportBody(dog));
|
||||
});
|
||||
|
||||
// Impfung löschen
|
||||
wrap.querySelectorAll('.pp-vacc-del').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Impfung wirklich löschen?')) return;
|
||||
try {
|
||||
await API.del(`/passport/${dog.id}/vaccinations/${btn.dataset.id}`);
|
||||
_loadPassportBody(dog);
|
||||
} catch (e) {
|
||||
UI.toast.error(e.message || 'Fehler');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Medikament hinzufügen
|
||||
document.getElementById('pp-med-add-btn')?.addEventListener('click', () => {
|
||||
_addMedication(dog, () => _loadPassportBody(dog));
|
||||
});
|
||||
|
||||
// Medikament löschen
|
||||
wrap.querySelectorAll('.pp-med-del').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Medikament wirklich löschen?')) return;
|
||||
try {
|
||||
await API.del(`/passport/${dog.id}/medications/${btn.dataset.id}`);
|
||||
_loadPassportBody(dog);
|
||||
} catch (e) {
|
||||
UI.toast.error(e.message || 'Fehler');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _editPassportMeta(dog, current, onSave) {
|
||||
UI.modal.open({
|
||||
title: 'Gesundheits-Info bearbeiten',
|
||||
body: `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Blutgruppe</label>
|
||||
<input id="pp-meta-bg" class="form-control" type="text"
|
||||
value="${_esc(current.blutgruppe || '')}" placeholder="z. B. DEA 1.1 positiv">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Allergien</label>
|
||||
<textarea id="pp-meta-al" class="form-control" rows="2"
|
||||
placeholder="z. B. Hühnchen, Flohspeichel">${_esc(current.allergien || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Besonderheiten</label>
|
||||
<textarea id="pp-meta-be" class="form-control" rows="2"
|
||||
placeholder="z. B. Herzprobleme, Angstpatient">${_esc(current.besonderheiten || '')}</textarea>
|
||||
</div>`,
|
||||
footer: `
|
||||
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="pp-meta-save">Speichern</button>
|
||||
</div>`,
|
||||
});
|
||||
|
||||
document.getElementById('pp-meta-save').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('pp-meta-save');
|
||||
UI.setLoading(btn, true);
|
||||
try {
|
||||
await API.put(`/passport/${dog.id}/meta`, {
|
||||
blutgruppe: document.getElementById('pp-meta-bg').value.trim() || null,
|
||||
allergien: document.getElementById('pp-meta-al').value.trim() || null,
|
||||
besonderheiten: document.getElementById('pp-meta-be').value.trim() || null,
|
||||
});
|
||||
UI.modal.close();
|
||||
UI.toast.success('Gesundheits-Info gespeichert.');
|
||||
onSave();
|
||||
} catch (e) {
|
||||
UI.setLoading(btn, false);
|
||||
UI.toast.error(e.message || 'Fehler');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _addVaccination(dog, onSave) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
UI.modal.open({
|
||||
title: 'Impfung eintragen',
|
||||
body: `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Krankheit *</label>
|
||||
<input id="pp-vacc-krankheit" class="form-control" type="text"
|
||||
placeholder="z. B. Staupe, Parvovirose, Tollwut, DHPP" list="pp-vacc-list">
|
||||
<datalist id="pp-vacc-list">
|
||||
<option value="Staupe">
|
||||
<option value="Parvovirose">
|
||||
<option value="Hepatitis (HCC)">
|
||||
<option value="Leptospirose">
|
||||
<option value="Tollwut">
|
||||
<option value="Kennel-Husten (Bordetella)">
|
||||
<option value="Borreliose">
|
||||
<option value="DHPP (Kombi)">
|
||||
</datalist>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Datum *</label>
|
||||
<input id="pp-vacc-datum" class="form-control" type="date" value="${today}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nächste fällig</label>
|
||||
<input id="pp-vacc-naechste" class="form-control" type="date">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tierarzt</label>
|
||||
<input id="pp-vacc-tierarzt" class="form-control" type="text" placeholder="Name der Praxis">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Charge-Nr.</label>
|
||||
<input id="pp-vacc-charge" class="form-control" type="text" placeholder="Chargennummer des Impfstoffs">
|
||||
</div>`,
|
||||
footer: `
|
||||
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="pp-vacc-save">Speichern</button>
|
||||
</div>`,
|
||||
});
|
||||
|
||||
document.getElementById('pp-vacc-save').addEventListener('click', async () => {
|
||||
const krankheit = document.getElementById('pp-vacc-krankheit').value.trim();
|
||||
const datum = document.getElementById('pp-vacc-datum').value;
|
||||
if (!krankheit || !datum) {
|
||||
UI.toast.warning('Bitte Krankheit und Datum angeben.');
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('pp-vacc-save');
|
||||
UI.setLoading(btn, true);
|
||||
try {
|
||||
await API.post(`/passport/${dog.id}/vaccinations`, {
|
||||
krankheit,
|
||||
datum,
|
||||
naechste: document.getElementById('pp-vacc-naechste').value || null,
|
||||
tierarzt: document.getElementById('pp-vacc-tierarzt').value.trim() || null,
|
||||
charge_nr: document.getElementById('pp-vacc-charge').value.trim() || null,
|
||||
});
|
||||
UI.modal.close();
|
||||
UI.toast.success('Impfung eingetragen.');
|
||||
onSave();
|
||||
} catch (e) {
|
||||
UI.setLoading(btn, false);
|
||||
UI.toast.error(e.message || 'Fehler');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _addMedication(dog, onSave) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
UI.modal.open({
|
||||
title: 'Medikament eintragen',
|
||||
body: `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Medikament *</label>
|
||||
<input id="pp-med-name" class="form-control" type="text"
|
||||
placeholder="z. B. Frontline, Milbemax, Onsior">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Dosierung</label>
|
||||
<input id="pp-med-dosierung" class="form-control" type="text"
|
||||
placeholder="z. B. 1× täglich, 5 mg">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Von</label>
|
||||
<input id="pp-med-von" class="form-control" type="date" value="${today}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Bis <span style="color:var(--c-text-muted)">(leer = dauerhaft)</span></label>
|
||||
<input id="pp-med-bis" class="form-control" type="date">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Notiz</label>
|
||||
<input id="pp-med-notiz" class="form-control" type="text"
|
||||
placeholder="z. B. nach dem Fressen geben">
|
||||
</div>`,
|
||||
footer: `
|
||||
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="pp-med-save">Speichern</button>
|
||||
</div>`,
|
||||
});
|
||||
|
||||
document.getElementById('pp-med-save').addEventListener('click', async () => {
|
||||
const name = document.getElementById('pp-med-name').value.trim();
|
||||
if (!name) {
|
||||
UI.toast.warning('Bitte einen Namen angeben.');
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('pp-med-save');
|
||||
UI.setLoading(btn, true);
|
||||
try {
|
||||
await API.post(`/passport/${dog.id}/medications`, {
|
||||
name,
|
||||
dosierung: document.getElementById('pp-med-dosierung').value.trim() || null,
|
||||
von: document.getElementById('pp-med-von').value || null,
|
||||
bis: document.getElementById('pp-med-bis').value || null,
|
||||
notiz: document.getElementById('pp-med-notiz').value.trim() || null,
|
||||
});
|
||||
UI.modal.close();
|
||||
UI.toast.success('Medikament eingetragen.');
|
||||
onSave();
|
||||
} catch (e) {
|
||||
UI.setLoading(btn, false);
|
||||
UI.toast.error(e.message || 'Fehler');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function _createPassportShare(dog) {
|
||||
const btn = document.getElementById('pp-share-btn');
|
||||
if (btn) UI.setLoading(btn, true);
|
||||
try {
|
||||
const res = await API.post(`/passport/${dog.id}/share`, {});
|
||||
const url = `${location.origin}${res.url}`;
|
||||
if (btn) UI.setLoading(btn, false);
|
||||
// Zeige Share-Link im Modal (window.confirm wäre zu kurz)
|
||||
const shareWrap = document.createElement('div');
|
||||
shareWrap.innerHTML = `
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
|
||||
Dieser Link ist 30 Tage gültig. Tierärzte und Sitter können den Pass ohne Login öffnen.
|
||||
</p>
|
||||
<div style="display:flex;gap:var(--space-2);align-items:center">
|
||||
<input id="pp-sharelink-input" class="form-control" type="text" readonly
|
||||
value="${_esc(url)}" style="font-size:var(--text-xs)">
|
||||
<button class="btn btn-secondary btn-sm" id="pp-sharelink-copy" style="flex-shrink:0">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
|
||||
Gültig bis: ${res.valid_until.split('-').reverse().join('.')}
|
||||
</p>`;
|
||||
UI.modal.open({
|
||||
title: 'Hundepass-Link teilen',
|
||||
body: shareWrap.innerHTML,
|
||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
|
||||
});
|
||||
document.getElementById('pp-sharelink-copy')?.addEventListener('click', async () => {
|
||||
await navigator.clipboard.writeText(url).catch(() => {});
|
||||
UI.toast.success('Link kopiert!');
|
||||
});
|
||||
} catch (e) {
|
||||
if (btn) UI.setLoading(btn, false);
|
||||
UI.toast.error(e.message || 'Fehler beim Erstellen des Links.');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
828
backend/static/js/pages/expenses.js
Normal file
828
backend/static/js/pages/expenses.js
Normal file
|
|
@ -0,0 +1,828 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Ausgaben-Tracker
|
||||
Tabs: Übersicht | Einträge | Statistik
|
||||
============================================================ */
|
||||
|
||||
window.Page_expenses = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _tab = 'uebersicht';
|
||||
|
||||
// Cache
|
||||
let _summary = null;
|
||||
let _entries = [];
|
||||
let _statsData = null;
|
||||
|
||||
const TABS = [
|
||||
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
|
||||
{ id: 'eintraege', label: 'Ausgaben', icon: 'currency-eur' },
|
||||
{ id: 'dauerauftraege', label: 'Daueraufträge', icon: 'arrows-clockwise' },
|
||||
{ id: 'statistik', label: 'Statistik', icon: 'chart-bar' },
|
||||
];
|
||||
|
||||
const KATEGORIEN = [
|
||||
{ id: 'tierarzt', label: 'Tierarzt', icon: 'syringe', color: '#ef4444' },
|
||||
{ id: 'futter', label: 'Futter', icon: 'bowl-food', color: '#f59e0b' },
|
||||
{ id: 'zubehoer', label: 'Zubehör', icon: 'shopping-bag', color: '#8b5cf6' },
|
||||
{ id: 'versicherung',label: 'Versicherung',icon: 'shield', color: '#3b82f6' },
|
||||
{ id: 'sitter', label: 'Sitter', icon: 'paw-print', color: '#10b981' },
|
||||
{ id: 'sonstiges', label: 'Sonstiges', icon: 'receipt', color: '#6b7280' },
|
||||
];
|
||||
|
||||
function _kat(id) {
|
||||
return KATEGORIEN.find(k => k.id === id) || { id, label: id, icon: 'receipt', color: '#6b7280' };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// LIFECYCLE
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_summary = null;
|
||||
_entries = [];
|
||||
_statsData = null;
|
||||
_render();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
_summary = null;
|
||||
_entries = [];
|
||||
_statsData = null;
|
||||
await _renderTab();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SHELL
|
||||
// ----------------------------------------------------------
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<div class="by-tabs exp-tabs" id="exp-tabs">
|
||||
${TABS.map(t => `
|
||||
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
|
||||
${UI.icon(t.icon)} ${t.label}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div id="exp-content"></div>
|
||||
<button class="exp-fab" id="exp-fab" title="Neue Ausgabe">
|
||||
${UI.icon('plus')}
|
||||
</button>
|
||||
`;
|
||||
|
||||
_container.querySelectorAll('#exp-tabs .by-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_tab = btn.dataset.tab;
|
||||
_container.querySelectorAll('#exp-tabs .by-tab').forEach(b =>
|
||||
b.classList.toggle('active', b.dataset.tab === _tab)
|
||||
);
|
||||
_renderTab();
|
||||
});
|
||||
});
|
||||
|
||||
_container.querySelector('#exp-fab')
|
||||
?.addEventListener('click', () => _showForm(null));
|
||||
|
||||
_renderTab();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB ROUTER
|
||||
// ----------------------------------------------------------
|
||||
async function _renderTab() {
|
||||
const el = _container.querySelector('#exp-content');
|
||||
if (!el) return;
|
||||
el.innerHTML = `<div class="exp-loading">${UI.skeleton(4)}</div>`;
|
||||
try {
|
||||
switch (_tab) {
|
||||
case 'uebersicht': await _renderUebersicht(el); break;
|
||||
case 'eintraege': await _renderEintraege(el); break;
|
||||
case 'dauerauftraege': await _renderDauerauftraege(el); break;
|
||||
case 'statistik': await _renderStatistik(el); break;
|
||||
}
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="exp-error">Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB: ÜBERSICHT
|
||||
// ----------------------------------------------------------
|
||||
async function _renderUebersicht(el) {
|
||||
if (!_summary) {
|
||||
_summary = await API.get('/expenses/summary');
|
||||
}
|
||||
const s = _summary;
|
||||
|
||||
// Vormonatsvergleich berechnen
|
||||
const letzteMonat = await _getLetzteMonateData();
|
||||
const trendHtml = _trendHtml(letzteMonat);
|
||||
|
||||
const kacheln = KATEGORIEN.map(k => {
|
||||
const betrag = s.monat[k.id] || 0;
|
||||
return `
|
||||
<div class="exp-kachel">
|
||||
<div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
|
||||
${UI.icon(k.icon)}
|
||||
</div>
|
||||
<div class="exp-kachel-betrag" style="color:var(--c-primary)">${_fmt(betrag)}</div>
|
||||
<div class="exp-kachel-label">${k.label}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const verlauf = letzteMonat.length > 1 ? _vergleichHtml(letzteMonat) : '';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="exp-hero-card">
|
||||
<div class="exp-hero-label">Dieser Monat</div>
|
||||
<div class="exp-hero-betrag">${_fmt(s.gesamt_monat)}</div>
|
||||
<div class="exp-hero-meta">
|
||||
${UI.icon('calendar')} Dieses Jahr: <strong>${_fmt(s.gesamt_jahr)}</strong>
|
||||
${trendHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="exp-kachel-grid">${kacheln}</div>
|
||||
${verlauf}
|
||||
<div style="height:80px"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function _getLetzteMonateData() {
|
||||
if (!_entries.length) {
|
||||
_entries = await API.get('/expenses?limit=500');
|
||||
}
|
||||
const monatMap = {};
|
||||
_entries.forEach(e => {
|
||||
const m = e.datum.substring(0, 7);
|
||||
monatMap[m] = (monatMap[m] || 0) + e.betrag;
|
||||
});
|
||||
return Object.entries(monatMap)
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.slice(0, 6)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
function _trendHtml(data) {
|
||||
// Vergleich: aktueller Monat vs. Vormonat
|
||||
if (data.length < 2) return '';
|
||||
const aktuell = data[data.length - 1][1];
|
||||
const vormonat = data[data.length - 2][1];
|
||||
if (!vormonat) return '';
|
||||
const diff = aktuell - vormonat;
|
||||
const pct = Math.round(Math.abs(diff / vormonat) * 100);
|
||||
if (pct === 0) return '';
|
||||
const pfeil = diff > 0
|
||||
? `<span class="exp-trend exp-trend--up">${UI.icon('arrow-up')} +${pct}% ggü. Vormonat</span>`
|
||||
: `<span class="exp-trend exp-trend--down">${UI.icon('arrow-down')} −${pct}% ggü. Vormonat</span>`;
|
||||
return pfeil;
|
||||
}
|
||||
|
||||
function _vergleichHtml(data) {
|
||||
if (!data.length) return '';
|
||||
const max = Math.max(...data.map(d => d[1]), 1);
|
||||
const balken = data.map(([monat, summe]) => {
|
||||
const pct = Math.round((summe / max) * 100);
|
||||
const [y, m] = monat.split('-');
|
||||
const label = new Date(parseInt(y), parseInt(m) - 1, 1)
|
||||
.toLocaleString('de-DE', { month: 'short' });
|
||||
return `
|
||||
<div class="exp-bar-item">
|
||||
<div class="exp-bar-track">
|
||||
<div class="exp-bar-fill" style="height:${pct}%"></div>
|
||||
</div>
|
||||
<div class="exp-bar-label">${label}</div>
|
||||
<div class="exp-bar-val">${_fmtShort(summe)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="exp-section">
|
||||
<div class="exp-section-title">${UI.icon('chart-bar')} Verlauf (6 Monate)</div>
|
||||
<div class="exp-bar-chart">${balken}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB: EINTRÄGE
|
||||
// ----------------------------------------------------------
|
||||
async function _renderEintraege(el) {
|
||||
if (!_entries.length) {
|
||||
_entries = await API.get('/expenses?limit=500');
|
||||
}
|
||||
|
||||
if (!_entries.length) {
|
||||
el.innerHTML = UI.emptyState({
|
||||
icon: UI.icon('receipt'),
|
||||
title: 'Noch keine Ausgaben',
|
||||
text: 'Tippe auf + um deine erste Ausgabe einzutragen.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Nach Monat gruppieren
|
||||
const groups = {};
|
||||
_entries.forEach(e => {
|
||||
const m = e.datum.substring(0, 7);
|
||||
if (!groups[m]) groups[m] = [];
|
||||
groups[m].push(e);
|
||||
});
|
||||
|
||||
const html = Object.entries(groups)
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.map(([monat, items]) => {
|
||||
const [y, m] = monat.split('-');
|
||||
const titel = new Date(parseInt(y), parseInt(m) - 1, 1)
|
||||
.toLocaleString('de-DE', { month: 'long', year: 'numeric' });
|
||||
const summe = items.reduce((s, e) => s + e.betrag, 0);
|
||||
|
||||
const rows = items.map(e => {
|
||||
const k = _kat(e.kategorie);
|
||||
const datum = new Date(e.datum + 'T00:00:00')
|
||||
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
const dogBadge = e.dog_name
|
||||
? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(e.dog_name)}</span>`
|
||||
: '';
|
||||
const notiz = e.notiz
|
||||
? `<span class="exp-entry-notiz">${_esc(e.notiz)}</span>`
|
||||
: '';
|
||||
return `
|
||||
<div class="exp-entry" data-id="${e.id}">
|
||||
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">
|
||||
${UI.icon(k.icon)}
|
||||
</div>
|
||||
<div class="exp-entry-body">
|
||||
<div class="exp-entry-head">
|
||||
<span class="exp-entry-datum">${datum}</span>
|
||||
<span class="exp-entry-kat">${k.label}</span>
|
||||
${dogBadge}
|
||||
</div>
|
||||
${notiz}
|
||||
</div>
|
||||
<div class="exp-entry-right">
|
||||
<div class="exp-entry-betrag">${_fmt(e.betrag)}</div>
|
||||
<button class="exp-entry-del" data-del="${e.id}" title="Löschen"
|
||||
aria-label="Eintrag löschen">
|
||||
${UI.icon('trash')}
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="exp-month-group">
|
||||
<div class="exp-month-header">
|
||||
<span class="exp-month-title">${titel}</span>
|
||||
<span class="exp-month-summe">${_fmt(summe)}</span>
|
||||
</div>
|
||||
${rows}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = `<div class="exp-list">${html}</div><div style="height:80px"></div>`;
|
||||
|
||||
// Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button)
|
||||
el.querySelectorAll('.exp-entry').forEach(row => {
|
||||
row.addEventListener('click', (ev) => {
|
||||
if (ev.target.closest('.exp-entry-del')) return;
|
||||
const id = parseInt(row.dataset.id);
|
||||
const entry = _entries.find(e => e.id === id);
|
||||
if (entry) _showForm(entry);
|
||||
});
|
||||
});
|
||||
|
||||
// Löschen-Buttons
|
||||
el.querySelectorAll('.exp-entry-del').forEach(btn => {
|
||||
btn.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
const id = parseInt(btn.dataset.del);
|
||||
if (!window.confirm('Diesen Eintrag wirklich löschen?')) return;
|
||||
try {
|
||||
await API.del(`/expenses/${id}`);
|
||||
UI.toast.success('Ausgabe gelöscht.');
|
||||
_invalidateCache();
|
||||
await _renderTab();
|
||||
} catch (e) {
|
||||
UI.toast.error(e.message || 'Fehler beim Löschen.');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB: DAUERAUFTRÄGE
|
||||
// ----------------------------------------------------------
|
||||
const HAEUFIGKEIT_LABEL = {
|
||||
monatlich: 'Monatlich',
|
||||
quartalsweise: 'Quartalsweise',
|
||||
jaehrlich: 'Jährlich',
|
||||
};
|
||||
|
||||
async function _renderDauerauftraege(el) {
|
||||
let recurring = [];
|
||||
try { recurring = await API.get('/expenses/recurring'); } catch { /* leer */ }
|
||||
|
||||
const cards = recurring.map(r => {
|
||||
const k = _kat(r.kategorie);
|
||||
const naechste = r.naechste_faelligkeit
|
||||
? new Date(r.naechste_faelligkeit + 'T00:00:00')
|
||||
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: '—';
|
||||
return `
|
||||
<div class="exp-recurring-card${r.aktiv ? '' : ' exp-recurring-card--inaktiv'}" data-rid="${r.id}">
|
||||
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">${UI.icon(k.icon)}</div>
|
||||
<div class="exp-entry-body">
|
||||
<div class="exp-entry-head">
|
||||
<span class="exp-entry-kat">${k.label}</span>
|
||||
<span class="exp-recurring-freq">${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span>
|
||||
${r.dog_name ? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(r.dog_name)}</span>` : ''}
|
||||
</div>
|
||||
${r.notiz ? `<div class="exp-entry-notiz">${_esc(r.notiz)}</div>` : ''}
|
||||
<div class="exp-recurring-next">
|
||||
${UI.icon('calendar')} Nächste Buchung: <strong>${naechste}</strong>
|
||||
${!r.aktiv ? '<span class="exp-badge-inaktiv">Pausiert</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="exp-entry-right">
|
||||
<div class="exp-entry-betrag">${_fmt(r.betrag)}</div>
|
||||
<div style="display:flex;gap:var(--space-1);margin-top:var(--space-1)">
|
||||
<button class="exp-icon-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}"
|
||||
title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}">
|
||||
${UI.icon(r.aktiv ? 'pause' : 'play')}
|
||||
</button>
|
||||
<button class="exp-icon-btn exp-icon-btn--danger exp-recurring-del" data-rid="${r.id}"
|
||||
title="Löschen">${UI.icon('trash')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||
<button class="btn btn-primary btn-sm" id="exp-recurring-add">
|
||||
${UI.icon('plus')} Dauerauftrag
|
||||
</button>
|
||||
</div>
|
||||
${recurring.length
|
||||
? `<div class="exp-list">${cards}</div>`
|
||||
: UI.emptyState({ icon: UI.icon('arrows-clockwise'),
|
||||
title: 'Keine Daueraufträge',
|
||||
text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })}
|
||||
<div style="height:80px"></div>`;
|
||||
|
||||
el.querySelector('#exp-recurring-add')?.addEventListener('click', () => _showRecurringForm(null, () => {
|
||||
_tab = 'dauerauftraege'; _renderTab();
|
||||
}));
|
||||
|
||||
el.querySelectorAll('.exp-recurring-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const rid = parseInt(btn.dataset.rid);
|
||||
const aktiv = btn.dataset.aktiv === '1';
|
||||
await UI.asyncButton(btn, async () => {
|
||||
await API.patch(`/expenses/recurring/${rid}`, { aktiv: !aktiv });
|
||||
_renderTab();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.exp-recurring-del').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!window.confirm('Dauerauftrag löschen?')) return;
|
||||
await UI.asyncButton(btn, async () => {
|
||||
await API.del(`/expenses/recurring/${btn.dataset.rid}`);
|
||||
_renderTab();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _showRecurringForm(r, onSave) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const katOptions = [
|
||||
{ id: 'tierarzt', label: 'Tierarzt' }, { id: 'futter', label: 'Futter' },
|
||||
{ id: 'zubehoer', label: 'Zubehör' }, { id: 'versicherung', label: 'Versicherung' },
|
||||
{ id: 'sitter', label: 'Sitter' }, { id: 'sonstiges', label: 'Sonstiges' },
|
||||
].map(k => `<option value="${k.id}" ${r?.kategorie === k.id ? 'selected' : ''}>${k.label}</option>`).join('');
|
||||
|
||||
const dogOptions = (_appState.dogs || []).map(d =>
|
||||
`<option value="${d.id}" ${r?.dog_id === d.id ? 'selected' : ''}>${_esc(d.name)}</option>`
|
||||
).join('');
|
||||
|
||||
const body = `
|
||||
<form id="exp-recurring-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Kategorie</label>
|
||||
<select class="form-control" name="kategorie">${katOptions}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Betrag (€)</label>
|
||||
<input class="form-control" type="number" name="betrag" step="0.01" min="0.01"
|
||||
value="${r?.betrag || ''}" placeholder="0,00" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Häufigkeit</label>
|
||||
<select class="form-control" name="haeufigkeit">
|
||||
<option value="monatlich" ${r?.haeufigkeit === 'monatlich' ? 'selected' : ''}>Monatlich</option>
|
||||
<option value="quartalsweise" ${r?.haeufigkeit === 'quartalsweise' ? 'selected' : ''}>Quartalsweise (alle 3 Monate)</option>
|
||||
<option value="jaehrlich" ${r?.haeufigkeit === 'jaehrlich' ? 'selected' : ''}>Jährlich</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Startdatum</label>
|
||||
<input class="form-control" type="date" name="startdatum"
|
||||
value="${r?.startdatum || today}" required>
|
||||
</div>
|
||||
${dogOptions ? `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Hund <span style="color:var(--c-text-muted)">(optional)</span></label>
|
||||
<select class="form-control" name="dog_id">
|
||||
<option value="">Kein Hund</option>${dogOptions}
|
||||
</select>
|
||||
</div>` : ''}
|
||||
<div class="form-group">
|
||||
<label class="form-label">Bezeichnung <span style="color:var(--c-text-muted)">(optional)</span></label>
|
||||
<input class="form-control" type="text" name="notiz"
|
||||
value="${_esc(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
|
||||
</div>
|
||||
</form>`;
|
||||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button type="submit" form="exp-recurring-form" class="btn btn-primary flex-1">Speichern</button>`;
|
||||
|
||||
UI.modal.open({ title: r ? 'Dauerauftrag bearbeiten' : 'Neuer Dauerauftrag', body, footer });
|
||||
|
||||
document.getElementById('exp-recurring-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.querySelector('[form="exp-recurring-form"][type="submit"]');
|
||||
const fd = UI.formData(e.target);
|
||||
const payload = {
|
||||
kategorie: fd.kategorie,
|
||||
betrag: parseFloat(fd.betrag),
|
||||
haeufigkeit: fd.haeufigkeit,
|
||||
startdatum: fd.startdatum,
|
||||
notiz: fd.notiz || null,
|
||||
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
|
||||
};
|
||||
await UI.asyncButton(btn, async () => {
|
||||
if (r) {
|
||||
await API.patch(`/expenses/recurring/${r.id}`, payload);
|
||||
} else {
|
||||
await API.post('/expenses/recurring', payload);
|
||||
}
|
||||
UI.modal.close();
|
||||
onSave?.();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB: STATISTIK
|
||||
// ----------------------------------------------------------
|
||||
async function _renderStatistik(el) {
|
||||
if (!_summary) {
|
||||
_summary = await API.get('/expenses/summary');
|
||||
}
|
||||
if (!_entries.length) {
|
||||
_entries = await API.get('/expenses?limit=500');
|
||||
}
|
||||
|
||||
const s = _summary;
|
||||
const gesamtJahr = s.gesamt_jahr || 1;
|
||||
|
||||
// Jahres-Aufteilung nach Kategorien (als Balken-Reihen)
|
||||
const katBalken = KATEGORIEN
|
||||
.filter(k => (s.jahr[k.id] || 0) > 0)
|
||||
.sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0))
|
||||
.map(k => {
|
||||
const val = s.jahr[k.id] || 0;
|
||||
const pct = Math.round((val / gesamtJahr) * 100);
|
||||
return `
|
||||
<div class="exp-stat-row">
|
||||
<div class="exp-stat-label">
|
||||
<span class="exp-stat-icon" style="color:${k.color}">${UI.icon(k.icon)}</span>
|
||||
${k.label}
|
||||
</div>
|
||||
<div class="exp-stat-bar-wrap">
|
||||
<div class="exp-stat-bar" style="width:${pct}%;background:${k.color}"></div>
|
||||
</div>
|
||||
<div class="exp-stat-pct">${pct}%</div>
|
||||
<div class="exp-stat-val">${_fmt(val)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Monats-Balken mit gestapelten Top-2-Kategorien
|
||||
const heute = new Date();
|
||||
const jahrStr = heute.getFullYear().toString();
|
||||
|
||||
// Pro Monat: Summe je Kategorie berechnen
|
||||
const monatKatMap = {}; // { monat: { katId: summe } }
|
||||
_entries
|
||||
.filter(e => e.datum.startsWith(jahrStr))
|
||||
.forEach(e => {
|
||||
const m = parseInt(e.datum.split('-')[1]);
|
||||
if (!monatKatMap[m]) monatKatMap[m] = {};
|
||||
monatKatMap[m][e.kategorie] = (monatKatMap[m][e.kategorie] || 0) + e.betrag;
|
||||
});
|
||||
|
||||
const monatTotalMap = {};
|
||||
Object.entries(monatKatMap).forEach(([m, katObj]) => {
|
||||
monatTotalMap[m] = Object.values(katObj).reduce((a, b) => a + b, 0);
|
||||
});
|
||||
|
||||
const maxMonat = Math.max(...Object.values(monatTotalMap), 1);
|
||||
const MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
|
||||
|
||||
const monatsBalken = MONATE.map((label, i) => {
|
||||
const mi = i + 1;
|
||||
const total = monatTotalMap[mi] || 0;
|
||||
const pct = Math.round((total / maxMonat) * 100);
|
||||
const isAktiv = mi === (heute.getMonth() + 1);
|
||||
|
||||
// Top-2-Kategorien für gestapelten Balken
|
||||
let stackHtml = '';
|
||||
if (total > 0 && monatKatMap[mi]) {
|
||||
const sorted = Object.entries(monatKatMap[mi])
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 2);
|
||||
// Gesamthöhe = pct%, verteile anteilig auf Top-2
|
||||
let rest = pct;
|
||||
const segments = sorted.map(([katId, val], idx) => {
|
||||
const k = _kat(katId);
|
||||
const segPct = idx < sorted.length - 1
|
||||
? Math.round((val / total) * pct)
|
||||
: rest;
|
||||
rest -= segPct;
|
||||
return `<div class="exp-stack-seg" style="height:${segPct}%;background:${k.color}" title="${k.label}: ${_fmt(val)}"></div>`;
|
||||
});
|
||||
stackHtml = segments.join('');
|
||||
} else {
|
||||
stackHtml = `<div class="exp-stack-seg" style="height:${pct}%;background:var(--c-border)"></div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="exp-bar-item${isAktiv ? ' exp-bar-item--aktiv' : ''}">
|
||||
<div class="exp-bar-track exp-bar-track--stack">
|
||||
${stackHtml}
|
||||
</div>
|
||||
<div class="exp-bar-label">${label}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Donut-Übersicht (CSS-gradient)
|
||||
const donutHtml = _donutHtml(s, gesamtJahr);
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="exp-hero-card exp-hero-card--sm">
|
||||
<div class="exp-hero-label">Gesamt dieses Jahr</div>
|
||||
<div class="exp-hero-betrag">${_fmt(s.gesamt_jahr)}</div>
|
||||
</div>
|
||||
|
||||
<div class="exp-section">
|
||||
<div class="exp-section-title">${UI.icon('chart-bar')} Monatsübersicht ${jahrStr}</div>
|
||||
<div class="exp-bar-chart exp-bar-chart--12">${monatsBalken}</div>
|
||||
</div>
|
||||
|
||||
${donutHtml}
|
||||
|
||||
<div class="exp-section">
|
||||
<div class="exp-section-title">${UI.icon('chart-pie')} Aufteilung nach Kategorie</div>
|
||||
<div class="exp-stat-rows">
|
||||
${katBalken || `<div class="exp-empty-hint">Noch keine Ausgaben dieses Jahr.</div>`}
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:80px"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Donut via CSS conic-gradient
|
||||
function _donutHtml(s, gesamt) {
|
||||
const aktiveKat = KATEGORIEN.filter(k => (s.jahr[k.id] || 0) > 0);
|
||||
if (!aktiveKat.length) return '';
|
||||
|
||||
// Stops für conic-gradient berechnen
|
||||
let offset = 0;
|
||||
const stops = [];
|
||||
aktiveKat.forEach(k => {
|
||||
const pct = (s.jahr[k.id] || 0) / gesamt * 100;
|
||||
stops.push(`${k.color} ${offset.toFixed(1)}% ${(offset + pct).toFixed(1)}%`);
|
||||
offset += pct;
|
||||
});
|
||||
const gradient = `conic-gradient(${stops.join(', ')})`;
|
||||
|
||||
const legendeItems = aktiveKat
|
||||
.sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0))
|
||||
.map(k => {
|
||||
const pct = Math.round((s.jahr[k.id] || 0) / gesamt * 100);
|
||||
return `
|
||||
<div class="exp-donut-legend-item">
|
||||
<span class="exp-donut-dot" style="background:${k.color}"></span>
|
||||
<span class="exp-donut-legend-label">${k.label}</span>
|
||||
<span class="exp-donut-legend-pct">${pct}%</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="exp-section">
|
||||
<div class="exp-section-title">${UI.icon('chart-pie')} Kategorien-Verteilung</div>
|
||||
<div class="exp-donut-wrap">
|
||||
<div class="exp-donut" style="background:${gradient}">
|
||||
<div class="exp-donut-hole"></div>
|
||||
</div>
|
||||
<div class="exp-donut-legend">${legendeItems}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// FORMULAR — Neu / Bearbeiten
|
||||
// ----------------------------------------------------------
|
||||
function _showForm(entry) {
|
||||
const isEdit = !!entry;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const formId = 'exp-form';
|
||||
const selKat = entry?.kategorie || 'sonstiges';
|
||||
|
||||
const dogOptions = (_appState.dogs || []).map(d =>
|
||||
`<option value="${d.id}"${entry?.dog_id === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
|
||||
).join('');
|
||||
|
||||
// Kategorie-Kacheln statt Dropdown
|
||||
const katKacheln = KATEGORIEN.map(k => `
|
||||
<label class="exp-kat-tile${selKat === k.id ? ' exp-kat-tile--sel' : ''}" data-kat="${k.id}">
|
||||
<input type="radio" name="kategorie" value="${k.id}" ${selKat === k.id ? 'checked' : ''} style="display:none">
|
||||
<span class="exp-kat-tile-icon" style="color:${k.color}">${UI.icon(k.icon)}</span>
|
||||
<span class="exp-kat-tile-label">${k.label}</span>
|
||||
</label>`).join('');
|
||||
|
||||
const body = `
|
||||
<form id="${formId}" autocomplete="off">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Kategorie</label>
|
||||
<div class="exp-kat-grid">${katKacheln}</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label class="form-label">Betrag</label>
|
||||
<div class="exp-betrag-wrap">
|
||||
<span class="exp-betrag-prefix">€</span>
|
||||
<input type="number" name="betrag" class="form-control exp-betrag-input"
|
||||
value="${entry?.betrag || ''}" min="0.01" step="0.01"
|
||||
placeholder="0,00" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label class="form-label">Datum</label>
|
||||
<input type="date" name="datum" class="form-control"
|
||||
value="${entry?.datum || today}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${dogOptions ? `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Hund <span class="form-label-hint">(optional)</span></label>
|
||||
<select name="dog_id" class="form-control">
|
||||
<option value="">— kein Hund —</option>${dogOptions}
|
||||
</select>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Notiz <span class="form-label-hint">(optional)</span></label>
|
||||
<input type="text" name="notiz" class="form-control"
|
||||
value="${_esc(entry?.notiz || '')}"
|
||||
placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …">
|
||||
</div>
|
||||
|
||||
${!isEdit ? `
|
||||
<div class="exp-repeat-section">
|
||||
<label class="exp-repeat-toggle">
|
||||
<input type="checkbox" id="exp-wiederholen" name="wiederholen">
|
||||
<span class="exp-repeat-toggle-box"></span>
|
||||
<span>${UI.icon('arrows-clockwise')} Automatisch wiederholen</span>
|
||||
</label>
|
||||
<div id="exp-repeat-opts" style="display:none;margin-top:var(--space-3)">
|
||||
<select name="haeufigkeit" class="form-control">
|
||||
<option value="monatlich">Monatlich</option>
|
||||
<option value="quartalsweise">Quartalsweise (alle 3 Monate)</option>
|
||||
<option value="jaehrlich" selected>Jährlich</option>
|
||||
</select>
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-2) 0 0">
|
||||
Der Betrag wird automatisch zum Fälligkeitstermin eingetragen.
|
||||
</p>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
</form>`;
|
||||
|
||||
const footer = isEdit ? `
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="exp-delete-btn"
|
||||
style="color:var(--c-danger);margin-right:auto">
|
||||
${UI.icon('trash')}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button type="submit" form="${formId}" class="btn btn-primary">Speichern</button>
|
||||
` : `
|
||||
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button type="submit" form="${formId}" class="btn btn-primary flex-1">Speichern</button>
|
||||
`;
|
||||
|
||||
const modal = UI.modal.open({ title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe', body, footer });
|
||||
|
||||
// Kategorie-Kacheln interaktiv
|
||||
modal.querySelectorAll('.exp-kat-tile').forEach(tile => {
|
||||
tile.addEventListener('click', () => {
|
||||
modal.querySelectorAll('.exp-kat-tile').forEach(t => t.classList.remove('exp-kat-tile--sel'));
|
||||
tile.classList.add('exp-kat-tile--sel');
|
||||
});
|
||||
});
|
||||
|
||||
// Wiederholen-Toggle
|
||||
modal.querySelector('#exp-wiederholen')?.addEventListener('change', e => {
|
||||
modal.querySelector('#exp-repeat-opts').style.display = e.target.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
if (isEdit) {
|
||||
modal.querySelector('#exp-delete-btn')?.addEventListener('click', async () => {
|
||||
if (!window.confirm('Diesen Eintrag wirklich löschen?')) return;
|
||||
try {
|
||||
await API.del(`/expenses/${entry.id}`);
|
||||
UI.modal.close();
|
||||
UI.toast.success('Ausgabe gelöscht.');
|
||||
_invalidateCache();
|
||||
await _renderTab();
|
||||
} catch (e) {
|
||||
UI.toast.error(e.message || 'Fehler beim Löschen.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
modal.querySelector(`#${formId}`)?.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
const fd = UI.formData(ev.target);
|
||||
const payload = {
|
||||
kategorie: fd.kategorie,
|
||||
betrag: parseFloat(fd.betrag),
|
||||
datum: fd.datum,
|
||||
notiz: fd.notiz || null,
|
||||
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await API.patch(`/expenses/${entry.id}`, payload);
|
||||
UI.toast.success('Ausgabe aktualisiert.');
|
||||
} else {
|
||||
await API.post('/expenses', payload);
|
||||
// Auch als Dauerauftrag anlegen wenn gewünscht
|
||||
if (fd.wiederholen) {
|
||||
await API.post('/expenses/recurring', {
|
||||
...payload,
|
||||
haeufigkeit: fd.haeufigkeit || 'jaehrlich',
|
||||
startdatum: fd.datum,
|
||||
});
|
||||
UI.toast.success('Ausgabe + Dauerauftrag gespeichert.');
|
||||
} else {
|
||||
UI.toast.success('Ausgabe gespeichert.');
|
||||
}
|
||||
}
|
||||
UI.modal.close();
|
||||
_invalidateCache();
|
||||
await _renderTab();
|
||||
} catch (e) {
|
||||
UI.toast.error(e.message || 'Fehler beim Speichern.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
// ----------------------------------------------------------
|
||||
function _invalidateCache() {
|
||||
_summary = null;
|
||||
_entries = [];
|
||||
_statsData = null;
|
||||
}
|
||||
|
||||
function _fmt(val) {
|
||||
return (val || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
|
||||
}
|
||||
|
||||
function _fmtShort(val) {
|
||||
if (!val) return '0 €';
|
||||
if (val >= 1000) return (val / 1000).toFixed(1).replace('.', ',') + ' k€';
|
||||
return Math.round(val) + ' €';
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
return { init, refresh };
|
||||
})();
|
||||
|
|
@ -66,6 +66,7 @@ window.Page_forum = (() => {
|
|||
_container = container;
|
||||
_appState = appState;
|
||||
_render();
|
||||
_loadHdmCard();
|
||||
_loadThreads(true);
|
||||
}
|
||||
|
||||
|
|
@ -98,15 +99,17 @@ window.Page_forum = (() => {
|
|||
<div class="forum-category-tabs by-tabs" id="forum-tabs">
|
||||
${KATEGORIEN.map(k => `
|
||||
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}"
|
||||
data-kat="${k.key}">${_esc(k.label)}</button>
|
||||
data-kat="${k.key}"><span class="by-tab-text">${_esc(k.label)}</span></button>
|
||||
`).join('')}
|
||||
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
|
||||
data-section="map">${UI.icon('users')} Mitgliederkarte</button>
|
||||
data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button>
|
||||
</div>
|
||||
|
||||
<!-- Rechte Spalte: Suche + Threads -->
|
||||
<!-- Rechte Spalte: HdM-Kachel + Suche + Threads -->
|
||||
<div class="forum-main-col">
|
||||
|
||||
<div id="forum-hdm-card"></div>
|
||||
|
||||
<div class="forum-search-wrap">
|
||||
<input type="search" class="forum-search" id="forum-search"
|
||||
placeholder="Forum durchsuchen…" autocomplete="off">
|
||||
|
|
@ -127,6 +130,23 @@ window.Page_forum = (() => {
|
|||
const _tabCount = _tabsEl.querySelectorAll('.by-tab').length;
|
||||
_tabsEl.style.setProperty('--forum-tab-cols', Math.ceil(_tabCount / 2));
|
||||
|
||||
// Marquee-Scroll: nur Tabs animieren, bei denen Text wirklich abgeschnitten ist
|
||||
_tabsEl.addEventListener('mouseenter', e => {
|
||||
const btn = e.target.closest('.by-tab');
|
||||
const span = btn?.querySelector('.by-tab-text');
|
||||
if (!span) return;
|
||||
const style = getComputedStyle(btn);
|
||||
const padH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const overflow = span.scrollWidth - (btn.clientWidth - padH);
|
||||
if (overflow <= 2) return;
|
||||
span.style.setProperty('--tab-scroll-px', `-${overflow}px`);
|
||||
span.classList.add('scrolling');
|
||||
}, true);
|
||||
_tabsEl.addEventListener('mouseleave', e => {
|
||||
const span = e.target.closest('.by-tab')?.querySelector('.by-tab-text');
|
||||
if (span) span.classList.remove('scrolling');
|
||||
}, true);
|
||||
|
||||
// Tab-Klicks
|
||||
_tabsEl.addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-kat], [data-section]');
|
||||
|
|
@ -175,6 +195,177 @@ window.Page_forum = (() => {
|
|||
document.getElementById('forum-rules-btn').addEventListener('click', _showRules);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Hund des Monats — Kachel + Modal
|
||||
// ----------------------------------------------------------
|
||||
async function _loadHdmCard() {
|
||||
const card = document.getElementById('forum-hdm-card');
|
||||
if (!card) return;
|
||||
try {
|
||||
const data = await API.get('/movies/hund-des-monats');
|
||||
const [year, month] = data.monat.split('-');
|
||||
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long' })
|
||||
.format(new Date(+year, +month - 1, 1));
|
||||
const top = data.top?.[0];
|
||||
const winnerLine = top
|
||||
? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}`
|
||||
: 'Noch keine Stimmen';
|
||||
const metaLine = top
|
||||
? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}`
|
||||
: 'Sei der Erste!';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="forum-hdm-tile" id="forum-hdm-tile">
|
||||
<div class="forum-hdm-tile-trophy">🏆</div>
|
||||
<div class="forum-hdm-tile-body">
|
||||
<div class="forum-hdm-tile-title">Hund des Monats · ${_esc(monthName)}</div>
|
||||
<div class="forum-hdm-tile-winner">${winnerLine}</div>
|
||||
<div class="forum-hdm-tile-meta">${metaLine}</div>
|
||||
</div>
|
||||
<div class="forum-hdm-tile-cta">${UI.icon('arrow-right')}</div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('forum-hdm-tile')?.addEventListener('click', () => _openHdmModal(data));
|
||||
} catch {
|
||||
// Kachel bleibt leer bei Fehler
|
||||
}
|
||||
}
|
||||
|
||||
async function _openHdmModal(data) {
|
||||
try { data = await API.get('/movies/hund-des-monats'); } catch { /* gecachte Daten */ }
|
||||
|
||||
const [year, month] = data.monat.split('-');
|
||||
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
|
||||
.format(new Date(+year, +month - 1, 1));
|
||||
|
||||
const topList = data.top?.length
|
||||
? data.top.slice(0, 5).map((dog, i) => {
|
||||
const medal = ['🥇','🥈','🥉','4️⃣','5️⃣'][i];
|
||||
const av = dog.foto_url
|
||||
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
|
||||
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
|
||||
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
|
||||
return `
|
||||
<div class="hdm-top-entry">
|
||||
<span class="hdm-top-medal">${medal}</span>
|
||||
<div class="hdm-top-av">${av}</div>
|
||||
<div class="hdm-top-info">
|
||||
<div class="hdm-top-name">${_esc(dog.name)}</div>
|
||||
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
|
||||
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
|
||||
</div>
|
||||
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
|
||||
</div>`;
|
||||
}).join('')
|
||||
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen. Sei der Erste!</p>`;
|
||||
|
||||
const voteHint = !_appState.user
|
||||
? `<div class="hdm-section">
|
||||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||||
<a href="#" id="hdm-login-link" style="color:var(--c-primary);font-weight:var(--weight-semibold)">Anmelden</a>
|
||||
um abstimmen zu können.
|
||||
</p>
|
||||
</div>`
|
||||
: `<div class="hdm-section">
|
||||
<h3 class="hdm-section-title">Für welchen Hund möchtest du abstimmen?</h3>
|
||||
<div class="hdm-kandidaten-search">
|
||||
<input type="search" id="hdm-search" class="form-control"
|
||||
placeholder="Name oder Rasse suchen …" autocomplete="off"
|
||||
style="font-size:var(--text-sm)">
|
||||
</div>
|
||||
<div id="hdm-kandidaten-grid" class="hdm-vote-grid">
|
||||
${UI.skeleton(3)}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const body = `
|
||||
<div class="hdm-header">
|
||||
<div class="hdm-trophy">🏆</div>
|
||||
<h2 class="hdm-title">Hund des Monats</h2>
|
||||
<div class="hdm-monat">${_esc(monthName)}</div>
|
||||
</div>
|
||||
${voteHint}
|
||||
<div class="hdm-section">
|
||||
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
|
||||
<div id="hdm-top-list">${topList}</div>
|
||||
</div>`;
|
||||
|
||||
UI.modal.open({ title: '🏆 Hund des Monats', body,
|
||||
footer: `<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Schließen</button>` });
|
||||
|
||||
document.getElementById('hdm-login-link')?.addEventListener('click', e => {
|
||||
e.preventDefault(); UI.modal.close(); App.navigate('settings');
|
||||
});
|
||||
|
||||
if (!_appState.user) return;
|
||||
|
||||
// Kandidaten laden und rendern
|
||||
let _kandidaten = [];
|
||||
const _renderKandidaten = (list) => {
|
||||
const grid = document.getElementById('hdm-kandidaten-grid');
|
||||
if (!grid) return;
|
||||
if (!list.length) {
|
||||
grid.innerHTML = `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);padding:var(--space-3) 0">Keine Hunde gefunden.</p>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = list.map(dog => {
|
||||
const isVoted = data.user_vote === dog.id;
|
||||
const av = dog.foto_url
|
||||
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
|
||||
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
|
||||
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
|
||||
return `
|
||||
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}">
|
||||
<div class="hdm-vote-av">${av}</div>
|
||||
<div class="hdm-vote-name">${_esc(dog.name)}</div>
|
||||
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
|
||||
${vorname ? `<div class="hdm-vote-besitzer" style="font-size:var(--text-xs);color:var(--c-text-muted)">von ${vorname}</div>` : ''}
|
||||
${dog.stimmen > 0 ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${dog.stimmen} ${UI.icon('star')}</div>` : ''}
|
||||
<button class="btn btn-sm ${isVoted ? 'btn-primary' : 'btn-secondary'} hdm-vote-btn"
|
||||
data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}>
|
||||
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
grid.querySelectorAll('.hdm-vote-btn:not([disabled])').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const dogId = parseInt(btn.dataset.dogId);
|
||||
await UI.asyncButton(btn, async () => {
|
||||
try {
|
||||
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
|
||||
data.user_vote = dogId;
|
||||
UI.toast.success('Stimme abgegeben!');
|
||||
UI.modal.close();
|
||||
_loadHdmCard();
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
_kandidaten = await API.get('/movies/hund-des-monats/kandidaten');
|
||||
} catch {
|
||||
document.getElementById('hdm-kandidaten-grid').innerHTML =
|
||||
`<p style="color:var(--c-danger);font-size:var(--text-sm)">Kandidaten konnten nicht geladen werden.</p>`;
|
||||
return;
|
||||
}
|
||||
_renderKandidaten(_kandidaten);
|
||||
|
||||
document.getElementById('hdm-search')?.addEventListener('input', e => {
|
||||
const q = e.target.value.trim().toLowerCase();
|
||||
_renderKandidaten(q
|
||||
? _kandidaten.filter(d =>
|
||||
(d.name || '').toLowerCase().includes(q) ||
|
||||
(d.rasse || '').toLowerCase().includes(q))
|
||||
: _kandidaten
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Threads laden
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@
|
|||
|
||||
window.Page_health = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _data = {};
|
||||
let _praxen = [];
|
||||
let _activeTab = 'impfung';
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _data = {};
|
||||
let _praxen = [];
|
||||
let _activeTab = 'impfung';
|
||||
let _favoritVet = null;
|
||||
let _healthDocs = [];
|
||||
|
||||
const BASE_TABS = [
|
||||
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
|
||||
|
|
@ -20,7 +22,6 @@ window.Page_health = (() => {
|
|||
{ key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' },
|
||||
{ key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' },
|
||||
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
|
||||
{ key: 'symptomcheck', label: 'Symptom-Check', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>' },
|
||||
];
|
||||
const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' };
|
||||
|
||||
|
|
@ -150,6 +151,9 @@ window.Page_health = (() => {
|
|||
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
|
||||
${UI.icon('star')} KI-Zusammenfassung
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" id="health-ki-tierarzt-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt
|
||||
</button>
|
||||
</div>
|
||||
${transponderHtml}
|
||||
<div id="health-ki-berichte"></div>
|
||||
|
|
@ -162,6 +166,8 @@ window.Page_health = (() => {
|
|||
_renderTabBar();
|
||||
_container.querySelector('#health-ki-btn')
|
||||
.addEventListener('click', _showKiSummary);
|
||||
_container.querySelector('#health-ki-tierarzt-btn')
|
||||
.addEventListener('click', _showKiTierarzt);
|
||||
_container.querySelector('#health-transponder-edit')
|
||||
.addEventListener('click', () => _editTransponder(dog));
|
||||
|
||||
|
|
@ -170,6 +176,7 @@ window.Page_health = (() => {
|
|||
_renderTab();
|
||||
_loadKiBerichte(dog.id);
|
||||
_loadTerminvorschlaege(dog.id);
|
||||
_loadMeinTierarzt();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -342,6 +349,16 @@ window.Page_health = (() => {
|
|||
} catch (err) {
|
||||
_data['gewicht_chart'] = [];
|
||||
}
|
||||
try {
|
||||
_favoritVet = await API.tieraerzte.myFavorite();
|
||||
} catch (err) {
|
||||
_favoritVet = null;
|
||||
}
|
||||
try {
|
||||
_healthDocs = await API.healthDocs.list(dogId);
|
||||
} catch (err) {
|
||||
_healthDocs = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -362,7 +379,6 @@ window.Page_health = (() => {
|
|||
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
|
||||
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
|
||||
case 'praxen': content.innerHTML = _renderPraxen(); break;
|
||||
case 'symptomcheck': _renderSymptomCheck(content); break;
|
||||
}
|
||||
|
||||
_bindTabEvents(content);
|
||||
|
|
@ -901,7 +917,8 @@ window.Page_health = (() => {
|
|||
}).join('');
|
||||
|
||||
return `<div class="health-list">${items}</div>
|
||||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
|
||||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
|
||||
${_renderBefundeSection()}`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -957,6 +974,32 @@ window.Page_health = (() => {
|
|||
// Praxis hinzufügen
|
||||
content.querySelector('[data-action="add-praxis"]')
|
||||
?.addEventListener('click', () => _showPraxForm(null));
|
||||
// Favorit-Toggle für Praxen
|
||||
content.querySelectorAll('[data-action="toggle-fav"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const vetId = parseInt(btn.dataset.praxisId);
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const res = await API.tieraerzte.toggleFavorite(vetId);
|
||||
if (res.is_favorite) {
|
||||
_favoritVet = _praxen.find(p => p.id === vetId) || null;
|
||||
UI.toast.success('Als Favorit-Tierarzt gespeichert.');
|
||||
} else {
|
||||
_favoritVet = null;
|
||||
UI.toast.success('Favorit entfernt.');
|
||||
}
|
||||
// is_favorite in _praxen aktualisieren
|
||||
_praxen = _praxen.map(p => ({ ...p, is_favorite: p.id === vetId ? res.is_favorite : false }));
|
||||
const elFav = _container.querySelector('#health-mein-tierarzt');
|
||||
if (elFav) _renderMeinTierarztKachel(elFav);
|
||||
_renderTab();
|
||||
});
|
||||
});
|
||||
});
|
||||
// Befunde & Dokumente
|
||||
if (_activeTab === 'dokument') {
|
||||
_bindBefundeEvents(content);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -1597,7 +1640,9 @@ window.Page_health = (() => {
|
|||
action: addBtn
|
||||
});
|
||||
|
||||
const renderCard = p => `
|
||||
const renderCard = p => {
|
||||
const isFav = _favoritVet?.id === p.id || p.is_favorite;
|
||||
return `
|
||||
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
|
||||
data-praxis-id="${p.id}" data-action="open-praxis">
|
||||
<div style="font-size:1.5rem"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${p.ist_notfallpraxis ? 'warning' : 'first-aid'}"></use></svg></div>
|
||||
|
|
@ -1626,17 +1671,40 @@ window.Page_health = (() => {
|
|||
onclick="event.stopPropagation()">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
|
||||
</a>` : ''}
|
||||
<button class="btn btn-sm ${isFav ? 'btn-primary' : 'btn-secondary'}"
|
||||
data-action="toggle-fav" data-praxis-id="${p.id}"
|
||||
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
|
||||
style="flex-shrink:0"
|
||||
onclick="event.stopPropagation()">
|
||||
<svg class="ph-icon" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${isFav ? 'heart-fill' : 'heart'}"></use>
|
||||
</svg>
|
||||
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
`};
|
||||
|
||||
|
||||
const favCard = _favoritVet ? `
|
||||
<div style="margin-bottom:var(--space-4)">
|
||||
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-primary);
|
||||
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
|
||||
${UI.icon('heart')} Mein Tierarzt
|
||||
</div>
|
||||
${renderCard(_favoritVet)}
|
||||
</div>` : '';
|
||||
|
||||
const ohneGesetzt = aktive.filter(p => p.id !== _favoritVet?.id);
|
||||
|
||||
return `
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||
${addBtn}
|
||||
</div>
|
||||
${favCard}
|
||||
<div class="health-list">
|
||||
${aktive.map(renderCard).join('')}
|
||||
${ohneGesetzt.map(renderCard).join('')}
|
||||
${inaktive.length ? `
|
||||
<div style="margin-top:var(--space-4);padding-top:var(--space-3);
|
||||
border-top:1px solid var(--c-border)">
|
||||
|
|
@ -2156,6 +2224,306 @@ window.Page_health = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MEIN TIERARZT — Kachel
|
||||
// ----------------------------------------------------------
|
||||
async function _loadMeinTierarzt() {
|
||||
const el = _container.querySelector('#health-mein-tierarzt');
|
||||
if (!el) return;
|
||||
_renderMeinTierarztKachel(el);
|
||||
}
|
||||
|
||||
function _renderMeinTierarztKachel(el) {
|
||||
if (!el) return;
|
||||
const vet = _favoritVet;
|
||||
const adresse = vet
|
||||
? [vet.strasse, [vet.plz, vet.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ')
|
||||
: '';
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="margin:var(--space-3) var(--space-4) 0">
|
||||
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
|
||||
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
|
||||
Mein Tierarzt
|
||||
</div>
|
||||
<div class="health-card" style="align-items:flex-start">
|
||||
<div style="font-size:1.6rem;flex-shrink:0">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>
|
||||
</div>
|
||||
<div class="health-card-body" style="flex:1;min-width:0">
|
||||
${vet ? `
|
||||
<div class="health-card-title">${_esc(vet.name)}</div>
|
||||
${adresse ? `<div class="health-card-meta">${_esc(adresse)}</div>` : ''}
|
||||
${vet.telefon ? `
|
||||
<div style="margin-top:var(--space-2)">
|
||||
<a href="tel:${_esc(vet.telefon)}" class="btn btn-secondary btn-sm"
|
||||
onclick="event.stopPropagation()">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg> ${_esc(vet.telefon)}
|
||||
</a>
|
||||
</div>` : ''}
|
||||
${vet.notfall_telefon ? `
|
||||
<div style="margin-top:var(--space-1)">
|
||||
<a href="tel:${_esc(vet.notfall_telefon)}" class="btn btn-danger btn-sm"
|
||||
onclick="event.stopPropagation()">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall: ${_esc(vet.notfall_telefon)}
|
||||
</a>
|
||||
</div>` : ''}
|
||||
` : `
|
||||
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">
|
||||
Noch kein Tierarzt als Favorit gespeichert.
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" style="margin-top:var(--space-2)"
|
||||
id="health-suche-tierarzt-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Tierarzt suchen
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
${vet ? `
|
||||
<button class="btn btn-ghost btn-sm" id="health-remove-fav-btn"
|
||||
title="Als Favorit entfernen" style="flex-shrink:0;color:var(--c-danger)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart-fill"></use></svg>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
el.querySelector('#health-suche-tierarzt-btn')?.addEventListener('click', () => {
|
||||
App.navigate('map', { filter: 'tierarzt' });
|
||||
});
|
||||
|
||||
el.querySelector('#health-remove-fav-btn')?.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const btn = e.currentTarget;
|
||||
await UI.asyncButton(btn, async () => {
|
||||
await API.tieraerzte.toggleFavorite(_favoritVet.id);
|
||||
_favoritVet = null;
|
||||
const elAgain = _container.querySelector('#health-mein-tierarzt');
|
||||
if (elAgain) _renderMeinTierarztKachel(elAgain);
|
||||
UI.toast.success('Tierarzt-Favorit entfernt.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// BEFUNDE & DOKUMENTE — Sektion (unabhängig von den Tabs)
|
||||
// ----------------------------------------------------------
|
||||
// Diese Sektion erscheint im "dokument"-Tab als zweite Liste.
|
||||
// Wir ergänzen _renderDokumente um einen Abschnitt unten.
|
||||
|
||||
function _renderBefundeSection() {
|
||||
const dog = _appState.activeDog;
|
||||
const docs = _healthDocs;
|
||||
const DOC_ICONS = {
|
||||
blutbild: 'drop',
|
||||
roentgen: 'file-text',
|
||||
rezept: 'note',
|
||||
impfausweis:'certificate',
|
||||
sonstiges: 'file-text',
|
||||
};
|
||||
const DOC_LABELS = {
|
||||
blutbild: 'Blutbild',
|
||||
roentgen: 'Röntgen',
|
||||
rezept: 'Rezept',
|
||||
impfausweis:'Impfausweis',
|
||||
sonstiges: 'Sonstiges',
|
||||
};
|
||||
|
||||
const uploadBtn = `
|
||||
<button class="btn btn-primary btn-sm" id="health-docs-upload-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen
|
||||
</button>`;
|
||||
|
||||
const items = docs.length
|
||||
? docs.map(doc => {
|
||||
const icon = DOC_ICONS[doc.typ] || 'file-text';
|
||||
const label = DOC_LABELS[doc.typ] || doc.typ;
|
||||
const isImg = !['pdf'].includes(doc.file_type);
|
||||
const datum = doc.datum ? UI.time.format(doc.datum + 'T00:00:00') : '';
|
||||
return `
|
||||
<div class="health-card" style="align-items:flex-start">
|
||||
<div style="font-size:1.4rem;flex-shrink:0;color:var(--c-primary)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_esc(icon)}"></use></svg>
|
||||
</div>
|
||||
<div class="health-card-body" style="flex:1;min-width:0">
|
||||
<div class="health-card-title">${_esc(doc.titel)}</div>
|
||||
<div class="health-card-meta">
|
||||
${_esc(label)}${datum ? ' · ' + datum : ''}
|
||||
${doc.vet_name ? ' · <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ' + _esc(doc.vet_name) : ''}
|
||||
</div>
|
||||
${doc.beschreibung ? `<div class="health-card-note">${_esc(doc.beschreibung)}</div>` : ''}
|
||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
|
||||
<a href="${_esc(doc.file_path)}" target="_blank" rel="noopener"
|
||||
class="btn btn-secondary btn-sm" onclick="event.stopPropagation()">
|
||||
${isImg
|
||||
? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'
|
||||
: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen'}
|
||||
</a>
|
||||
<button class="btn btn-ghost btn-xs" style="color:var(--c-danger)"
|
||||
data-action="delete-hdoc" data-doc-id="${doc.id}"
|
||||
onclick="event.stopPropagation()">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')
|
||||
: `<p style="font-size:var(--text-sm);color:var(--c-text-muted);padding:var(--space-3) 0">
|
||||
Noch keine Befunde hochgeladen.
|
||||
</p>`;
|
||||
|
||||
return `
|
||||
<div style="margin-top:var(--space-5);padding-top:var(--space-4);
|
||||
border-top:1px solid var(--c-border)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
|
||||
text-transform:uppercase;letter-spacing:.05em">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> Befunde & Dokumente
|
||||
</div>
|
||||
${uploadBtn}
|
||||
</div>
|
||||
<div class="health-list" id="health-docs-list">${items}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _bindBefundeEvents(content) {
|
||||
content.querySelector('#health-docs-upload-btn')?.addEventListener('click', () => {
|
||||
_showBefundUploadModal();
|
||||
});
|
||||
content.querySelectorAll('[data-action="delete-hdoc"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const docId = parseInt(btn.dataset.docId);
|
||||
const ok = window.confirm('Befund wirklich löschen?');
|
||||
if (!ok) return;
|
||||
await UI.asyncButton(btn, async () => {
|
||||
await API.healthDocs.delete(docId);
|
||||
_healthDocs = _healthDocs.filter(d => d.id !== docId);
|
||||
_renderTab();
|
||||
UI.toast.success('Befund gelöscht.');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _showBefundUploadModal() {
|
||||
const aktivePraxen = _praxen.filter(p => p.aktiv);
|
||||
const dog = _appState.activeDog;
|
||||
|
||||
UI.modal.open({
|
||||
title: `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen`,
|
||||
body: `
|
||||
<form id="befund-form" autocomplete="off">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Art des Dokuments *</label>
|
||||
<select class="form-control" name="typ" required>
|
||||
<option value="">– bitte wählen –</option>
|
||||
<option value="blutbild">Blutbild</option>
|
||||
<option value="roentgen">Röntgen</option>
|
||||
<option value="rezept">Rezept</option>
|
||||
<option value="impfausweis">Impfausweis</option>
|
||||
<option value="sonstiges">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Titel *</label>
|
||||
<input class="form-control" type="text" name="titel" required
|
||||
placeholder="z.B. Blutbild März 2026">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Untersuchungsdatum</label>
|
||||
<input class="form-control" type="date" name="datum"
|
||||
value="${new Date().toISOString().slice(0,10)}">
|
||||
</div>
|
||||
${aktivePraxen.length ? `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tierarzt / Praxis</label>
|
||||
<select class="form-control" name="vet_id">
|
||||
<option value="">– optional –</option>
|
||||
${aktivePraxen.map(p =>
|
||||
`<option value="${p.id}">${_esc(p.name)}${p.ort ? ' · ' + _esc(p.ort) : ''}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>` : ''}
|
||||
<div class="form-group">
|
||||
<label class="form-label">Beschreibung</label>
|
||||
<textarea class="form-control" name="beschreibung" rows="2"
|
||||
placeholder="Zusätzliche Infos (optional)"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Datei * (PDF, JPG, PNG, WebP — max. 10 MB)</label>
|
||||
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;
|
||||
align-items:center;gap:var(--space-2)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Datei auswählen
|
||||
<input type="file" name="file" id="befund-file-input"
|
||||
accept=".pdf,image/*"
|
||||
required
|
||||
style="position:absolute;opacity:0;width:1px;height:1px">
|
||||
</label>
|
||||
<div id="befund-file-preview" style="margin-top:var(--space-2);font-size:var(--text-sm);
|
||||
color:var(--c-text-secondary)"></div>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="befund-cancel">Abbrechen</button>
|
||||
<button type="submit" form="befund-form" class="btn btn-primary flex-1">Hochladen</button>
|
||||
`,
|
||||
});
|
||||
|
||||
document.getElementById('befund-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
document.getElementById('befund-file-input')?.addEventListener('change', function () {
|
||||
const preview = document.getElementById('befund-file-preview');
|
||||
if (this.files?.length) {
|
||||
const f = this.files[0];
|
||||
preview.textContent = `${f.name} (${(f.size / 1024 / 1024).toFixed(2)} MB)`;
|
||||
} else {
|
||||
preview.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('befund-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.querySelector('[form="befund-form"][type="submit"]');
|
||||
const form = e.target;
|
||||
const fd = UI.formData(form);
|
||||
const fileInput = form.querySelector('[name="file"]');
|
||||
const file = fileInput?.files?.[0];
|
||||
|
||||
if (!fd.typ) { UI.toast.warning('Bitte Art des Dokuments wählen.'); return; }
|
||||
if (!fd.titel) { UI.toast.warning('Bitte Titel eingeben.'); return; }
|
||||
if (!file) { UI.toast.warning('Bitte eine Datei auswählen.'); return; }
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
UI.toast.error('Datei ist zu groß. Maximum: 10 MB.');
|
||||
return;
|
||||
}
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('dog_id', String(dog.id));
|
||||
formData.append('typ', fd.typ);
|
||||
formData.append('titel', fd.titel);
|
||||
formData.append('beschreibung', fd.beschreibung || '');
|
||||
formData.append('datum', fd.datum || '');
|
||||
if (fd.vet_id) formData.append('vet_id', fd.vet_id);
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const doc = await API.healthDocs.upload(formData);
|
||||
_healthDocs.unshift(doc);
|
||||
UI.modal.close();
|
||||
_renderTab();
|
||||
UI.toast.success('Befund hochgeladen.');
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Hochladen.');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
async function _showKiSummary() {
|
||||
const btn = _container.querySelector('#health-ki-btn');
|
||||
|
|
@ -2323,6 +2691,129 @@ window.Page_health = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// KI-TIERARZTFRAGEN
|
||||
// ----------------------------------------------------------
|
||||
function _showKiTierarzt() {
|
||||
const dog = _appState.activeDog;
|
||||
const dogName = dog?.name || '';
|
||||
const rasse = dog?.rasse || '';
|
||||
const placeholder = dogName
|
||||
? `Welche Symptome zeigt ${dogName}? z.B. frisst nicht, schläft viel, lahmt...`
|
||||
: 'Welche Symptome zeigt dein Hund? z.B. frisst nicht, schläft viel, lahmt...';
|
||||
|
||||
UI.modal.open({
|
||||
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt',
|
||||
body: `
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
|
||||
Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Orientierung —
|
||||
kein Ersatz für einen echten Tierarzt.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<textarea id="ki-tierarzt-symptom" class="form-control" rows="4"
|
||||
placeholder="${_esc(placeholder)}"></textarea>
|
||||
</div>
|
||||
<div id="ki-tierarzt-result" style="display:none"></div>
|
||||
<div style="margin-top:var(--space-3);padding:var(--space-3);
|
||||
background:#fff3cd;border-radius:var(--radius-md);
|
||||
font-size:var(--text-xs);color:#856404;
|
||||
border:1px solid #ffc107">
|
||||
<strong>⚠️ Hinweis:</strong> Dies ist keine medizinische Diagnose.
|
||||
Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt.
|
||||
</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
||||
<button class="btn btn-primary" id="ki-tierarzt-submit-btn">Frage stellen</button>`,
|
||||
});
|
||||
|
||||
document.getElementById('ki-tierarzt-submit-btn')
|
||||
.addEventListener('click', async function () {
|
||||
const btn = this;
|
||||
const symptom = document.getElementById('ki-tierarzt-symptom').value.trim();
|
||||
const resultEl = document.getElementById('ki-tierarzt-result');
|
||||
|
||||
if (!symptom) {
|
||||
UI.toast.warning('Bitte Symptome eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
resultEl.style.display = 'none';
|
||||
resultEl.innerHTML = '';
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await API.post('/ki/tierarzt', {
|
||||
symptom,
|
||||
dog_id: dog?.id || null,
|
||||
dog_name: dogName || null,
|
||||
rasse: rasse || null,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.status === 429) {
|
||||
resultEl.innerHTML = `
|
||||
<div style="padding:var(--space-3);background:var(--c-surface);
|
||||
border-radius:var(--radius-md);border:1px solid var(--c-warning);
|
||||
font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>
|
||||
5 Anfragen pro Tag erreicht. Morgen wieder verfügbar.
|
||||
</div>`;
|
||||
} else if (err.status === 503) {
|
||||
resultEl.innerHTML = `
|
||||
<div style="padding:var(--space-3);background:var(--c-surface);
|
||||
border-radius:var(--radius-md);border:1px solid var(--c-danger);
|
||||
font-size:var(--text-sm)">
|
||||
KI momentan nicht verfügbar. Bitte später versuchen.
|
||||
</div>`;
|
||||
} else {
|
||||
UI.toast.error(err.message || 'Fehler bei der KI-Anfrage.');
|
||||
return;
|
||||
}
|
||||
resultEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const antwortHtml = _esc(result.antwort)
|
||||
.replace(/\n\n/g, '</p><p style="margin:var(--space-2) 0">')
|
||||
.replace(/\n/g, '<br>');
|
||||
const restHtml = result.limit - result.anfragen_heute > 0
|
||||
? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
|
||||
Noch ${result.limit - result.anfragen_heute} von ${result.limit} Anfragen heute verfügbar.
|
||||
</p>`
|
||||
: `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
|
||||
Tageslimit erreicht. Morgen wieder verfügbar.
|
||||
</p>`;
|
||||
|
||||
resultEl.innerHTML = `
|
||||
<div style="margin-top:var(--space-4);padding:var(--space-4);
|
||||
background:var(--c-surface);border-radius:var(--radius-md);
|
||||
border:1px solid var(--c-border)">
|
||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
margin-bottom:var(--space-2);color:var(--c-text-secondary)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg>
|
||||
Einschätzung
|
||||
</div>
|
||||
<p style="font-size:var(--text-sm);line-height:1.7;margin:0">${antwortHtml}</p>
|
||||
${restHtml}
|
||||
</div>
|
||||
<div style="margin-top:var(--space-3);padding:var(--space-3);
|
||||
background:#fee2e2;border-radius:var(--radius-md);
|
||||
font-size:var(--text-xs);color:#991b1b;
|
||||
border:1px solid #fca5a5">
|
||||
<strong>⚠️ Dies ist keine medizinische Diagnose.</strong>
|
||||
Bei ernsthaften Symptomen sofort zum Tierarzt.
|
||||
</div>`;
|
||||
resultEl.style.display = '';
|
||||
|
||||
// Submit-Button ausblenden wenn Limit erschöpft
|
||||
if (result.anfragen_heute >= result.limit) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Limit erreicht';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { init, refresh, openNew, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
|
|||
269
backend/static/js/pages/jobs.js
Normal file
269
backend/static/js/pages/jobs.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Social-Media-Job Bewerbung
|
||||
============================================================ */
|
||||
|
||||
window.Page_jobs = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
|
||||
const _esc = s => UI.escape(s ?? '');
|
||||
const _ph = (name, size = 22) =>
|
||||
`<svg class="ph-icon" aria-hidden="true" style="width:${size}px;height:${size}px;flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
|
||||
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
await _render();
|
||||
}
|
||||
|
||||
async function _render() {
|
||||
// Bestehende Bewerbung prüfen (nur wenn eingeloggt)
|
||||
let existingApp = null;
|
||||
let trialStatus = null;
|
||||
if (_appState.user) {
|
||||
try {
|
||||
const r = await API.get('/jobs/my-application');
|
||||
existingApp = r.application;
|
||||
trialStatus = await API.get('/jobs/luna-trial-status');
|
||||
} catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
_container.innerHTML = `
|
||||
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="text-align:center;margin-bottom:var(--space-6)">
|
||||
<div style="font-size:48px;margin-bottom:var(--space-3)">🐾</div>
|
||||
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-2)">
|
||||
Social-Media-Manager/in gesucht
|
||||
</h1>
|
||||
<p style="color:var(--c-text-secondary);margin:0">
|
||||
Werde das Gesicht von Ban Yaro auf Instagram & TikTok
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stellenbeschreibung -->
|
||||
<div class="card" style="margin-bottom:var(--space-4)">
|
||||
<div style="padding:var(--space-5)">
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Die Stelle</h2>
|
||||
<div style="display:grid;gap:var(--space-3)">
|
||||
${_infoRow(_ph('map-pin'), 'Remote', '100 % flexibel — du arbeitest wann und wie du willst')}
|
||||
${_infoRow(_ph('calendar-dots'), 'Umfang', '1–2 Posts pro Woche auf Instagram & TikTok')}
|
||||
${_infoRow(_ph('tag'), 'Vergütung', '50 € / Monat — wächst mit der Community')}
|
||||
${_infoRow(_ph('robot'), 'Luna an deiner Seite', 'Unser KI-Assistent schreibt Captions, generiert Post-Ideen und Hashtags — du wählst aus und postest')}
|
||||
${_infoRow(_ph('star'), 'Gründer-Status', 'Du wirst Teil der ersten 100 Gründer — für immer')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Luna-Probezugang Teaser -->
|
||||
<div style="background:linear-gradient(135deg,var(--c-primary),#e8a857);border-radius:var(--radius-lg);
|
||||
padding:var(--space-5);margin-bottom:var(--space-4);color:#fff">
|
||||
<div style="font-size:var(--text-lg);font-weight:800;margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:24px;height:24px"><use href="/icons/phosphor.svg#robot"></use></svg>
|
||||
Luna 14 Tage kostenlos testen
|
||||
</div>
|
||||
<p style="margin:0;opacity:.9;font-size:var(--text-sm)">
|
||||
Mit deiner Bewerbung schalten wir dir sofort den vollen Zugang zu Luna frei —
|
||||
unserem KI-Assistenten für Social-Media-Posts. Probiere ihn einfach aus,
|
||||
bevor du dich entscheidest.
|
||||
</p>
|
||||
${trialStatus?.active ? `<div style="margin-top:var(--space-3);background:rgba(255,255,255,.2);
|
||||
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);font-weight:700;font-size:var(--text-sm)">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px;vertical-align:middle"><use href="/icons/phosphor.svg#check-circle"></use></svg>
|
||||
Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Wen wir suchen -->
|
||||
<div class="card" style="margin-bottom:var(--space-4)">
|
||||
<div style="padding:var(--space-5)">
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Wen wir suchen</h2>
|
||||
<ul style="margin:0;padding-left:var(--space-5);display:grid;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||||
<li>Du hast einen Hund — und liebst ihn sehr 🐕</li>
|
||||
<li>Du bist auf Instagram oder TikTok zuhause (nicht professionell, aber aktiv)</li>
|
||||
<li>Du schreibst gerne und authentisch auf Deutsch</li>
|
||||
<li>Du hast Lust, eine junge App bekannt zu machen — aus Überzeugung</li>
|
||||
<li>Kein Lebenslauf nötig. Kein Bewerbungs-Anschreiben. Einfach du.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bewerbungsformular oder Status -->
|
||||
${existingApp ? _renderStatus(existingApp) : _renderForm()}
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!existingApp) {
|
||||
_bindForm();
|
||||
}
|
||||
}
|
||||
|
||||
function _infoRow(icon, label, text) {
|
||||
return `
|
||||
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
|
||||
<div style="margin-top:1px">${icon}</div>
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:var(--text-sm)">${label}</div>
|
||||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${text}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderStatus(app) {
|
||||
const statusMap = {
|
||||
pending: { icon: 'clock', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' },
|
||||
reviewing: { icon: 'magnifying-glass', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' },
|
||||
accepted: { icon: 'check-circle', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' },
|
||||
rejected: { icon: 'x', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' },
|
||||
};
|
||||
const s = statusMap[app.status] || statusMap.pending;
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-5);text-align:center">
|
||||
<div style="margin-bottom:var(--space-3)">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:48px;height:48px;color:${s.color}"><use href="/icons/phosphor.svg#${s.icon}"></use></svg>
|
||||
</div>
|
||||
<div style="font-weight:700;color:${s.color};font-size:var(--text-lg);margin-bottom:var(--space-2)">${s.text}</div>
|
||||
<div style="color:var(--c-text-muted);font-size:var(--text-xs)">
|
||||
Bewerbung eingereicht: ${app.created_at?.slice(0,10)}
|
||||
</div>
|
||||
${app.admin_note ? `<div style="margin-top:var(--space-3);background:var(--c-surface-2);
|
||||
border-radius:var(--radius-md);padding:var(--space-3);font-size:var(--text-sm);
|
||||
color:var(--c-text-secondary);text-align:left">${_esc(app.admin_note)}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderForm() {
|
||||
const u = _appState.user;
|
||||
return `
|
||||
<div class="card">
|
||||
<div style="padding:var(--space-5)">
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">
|
||||
Jetzt bewerben
|
||||
</h2>
|
||||
<form id="jobs-form" novalidate>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Dein Name *</label>
|
||||
<input class="form-control" type="text" name="name"
|
||||
value="${u ? _esc(u.name) : ''}" placeholder="Vorname oder Nickname" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">E-Mail *</label>
|
||||
<input class="form-control" type="email" name="email"
|
||||
value="${u ? _esc(u.email || '') : ''}" placeholder="deine@email.de" required>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group" style="margin:0">
|
||||
<label class="form-label">Hunde-Name</label>
|
||||
<input class="form-control" type="text" name="dog_name" placeholder="z. B. Bella">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0">
|
||||
<label class="form-label">Rasse</label>
|
||||
<input class="form-control" type="text" name="dog_rasse" placeholder="z. B. Labrador">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Instagram oder TikTok Handle *</label>
|
||||
<div style="position:relative">
|
||||
<span style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
|
||||
color:var(--c-text-muted)">@</span>
|
||||
<input class="form-control" type="text" name="social_handle"
|
||||
style="padding-left:var(--space-7)" placeholder="dein_handle" required>
|
||||
</div>
|
||||
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
Dein öffentliches Profil auf Instagram oder TikTok
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Warum du? *</label>
|
||||
<textarea class="form-control" name="motivation" rows="5"
|
||||
placeholder="Erzähl uns kurz wer du bist, was dich an Ban Yaro begeistert und was du dir von der Stelle vorstellst. Kein formeller Ton nötig — schreib einfach wie du sprichst." required
|
||||
style="resize:vertical"></textarea>
|
||||
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
Mindestens 80 Zeichen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Anhänge (optional)</label>
|
||||
<input class="form-control" type="file" name="files" id="jobs-files"
|
||||
multiple accept=".pdf,.jpg,.jpeg,.png,.webp,.mp4,.mov"
|
||||
style="padding:var(--space-2)">
|
||||
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund — max. 3 Dateien, je 10 MB.
|
||||
PDF, Bild oder Video.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
${!u ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||
padding:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
margin-bottom:var(--space-4)">
|
||||
💡 <b>Tipp:</b> Wenn du dich vorher
|
||||
<a href="#" id="jobs-login-link" style="color:var(--c-primary)">anmeldest oder registrierst</a>,
|
||||
bekommst du sofort den 14-tägigen Luna-Probezugang.
|
||||
</div>` : ''}
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px"><use href="/icons/phosphor.svg#sparkle"></use></svg>
|
||||
Bewerbung absenden + Luna freischalten
|
||||
</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _bindForm() {
|
||||
document.getElementById('jobs-login-link')?.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (window.App) App.navigate('settings');
|
||||
});
|
||||
|
||||
document.getElementById('jobs-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('[type="submit"]');
|
||||
const fd = new FormData(e.target);
|
||||
|
||||
// Dateien aus file-input übernehmen
|
||||
const fileInput = document.getElementById('jobs-files');
|
||||
if (fileInput?.files?.length) {
|
||||
fd.delete('files');
|
||||
for (const f of fileInput.files) fd.append('files', f);
|
||||
}
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const resp = await fetch('/api/jobs/apply', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` },
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Fehler beim Absenden.');
|
||||
}
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.luna_trial) {
|
||||
UI.toast.success('🎉 Bewerbung eingegangen! Dein Luna-Probezugang ist jetzt aktiv.');
|
||||
// User-State aktualisieren damit Luna sofort zugänglich ist
|
||||
if (_appState.user && window.API) {
|
||||
try { _appState.user = await API.auth.me(); } catch { /* ignore */ }
|
||||
}
|
||||
} else {
|
||||
UI.toast.success('Bewerbung eingegangen! Wir melden uns bald.');
|
||||
}
|
||||
|
||||
await _render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
|
@ -13,6 +13,9 @@ window.Page_movies = (() => {
|
|||
let _filme = [];
|
||||
let _activeTab = 'filme';
|
||||
let _filter = 'alle';
|
||||
let _typ = 'alle'; // alle | film | serie | doku
|
||||
let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung
|
||||
let _search = '';
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
|
|
@ -39,7 +42,6 @@ window.Page_movies = (() => {
|
|||
<div class="movies-tabs">
|
||||
<button class="movies-tab${_activeTab === 'filme' ? ' movies-tab--active' : ''}" data-tab="filme">${UI.icon('film-slate')} Filme</button>
|
||||
<button class="movies-tab${_activeTab === 'promis' ? ' movies-tab--active' : ''}" data-tab="promis">${UI.icon('star')} Berühmtheiten</button>
|
||||
<button class="movies-tab${_activeTab === 'hdm' ? ' movies-tab--active' : ''}" data-tab="hdm">${UI.icon('paw-print')} Hund des Monats</button>
|
||||
</div>
|
||||
<div id="movies-tab-content"></div>
|
||||
`;
|
||||
|
|
@ -64,26 +66,54 @@ window.Page_movies = (() => {
|
|||
|
||||
if (_activeTab === 'filme') await _renderFilme(content);
|
||||
if (_activeTab === 'promis') _renderPromis(content);
|
||||
if (_activeTab === 'hdm') await _renderHundDesMonats(content);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB 1: FILME
|
||||
// ----------------------------------------------------------
|
||||
async function _loadFilme() {
|
||||
_filme = await API.get(`/movies/filme?sort=${_sort}&typ=${_typ}`);
|
||||
}
|
||||
|
||||
async function _renderFilme(content) {
|
||||
try {
|
||||
_filme = await API.get('/movies/filme');
|
||||
await _loadFilme();
|
||||
} catch {
|
||||
content.innerHTML = UI.emptyState({ icon: 'film-slate', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' });
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="movies-filter-row">
|
||||
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
|
||||
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</button>
|
||||
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> Hund überlebt</button>
|
||||
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg>+</button>
|
||||
<div class="movies-controls">
|
||||
<div class="movies-search-row">
|
||||
<svg class="ph-icon movies-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||||
<input type="search" id="movies-search" class="form-control movies-search-input"
|
||||
placeholder="Film, Serie oder Rasse suchen …" value="${_esc(_search)}" autocomplete="off">
|
||||
</div>
|
||||
<div class="movies-filter-row">
|
||||
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
|
||||
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</button>
|
||||
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> Hund überlebt</button>
|
||||
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg> Top</button>
|
||||
</div>
|
||||
<div class="movies-filter-row" style="margin-top:var(--space-2)">
|
||||
<button class="movies-filter-btn movies-type-btn${_typ === 'alle' ? ' movies-filter-btn--active' : ''}" data-typ="alle">Alle</button>
|
||||
<button class="movies-filter-btn movies-type-btn${_typ === 'film' ? ' movies-filter-btn--active' : ''}" data-typ="film"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#film-slate"></use></svg> Filme</button>
|
||||
<button class="movies-filter-btn movies-type-btn${_typ === 'serie' ? ' movies-filter-btn--active' : ''}" data-typ="serie"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#list"></use></svg> Serien</button>
|
||||
<button class="movies-filter-btn movies-type-btn${_typ === 'doku' ? ' movies-filter-btn--active' : ''}" data-typ="doku"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#camera"></use></svg> Dokus</button>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);margin-top:var(--space-2)">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px;color:var(--c-text-muted)"><use href="/icons/phosphor.svg#list"></use></svg>
|
||||
<select id="movies-sort" class="form-control" style="flex:1;font-size:var(--text-sm);padding:var(--space-2) var(--space-3)">
|
||||
<option value="default" ${_sort==='default' ?'selected':''}>Empfohlen</option>
|
||||
<option value="bewertung" ${_sort==='bewertung' ?'selected':''}>Community-Bewertung</option>
|
||||
<option value="imdb" ${_sort==='imdb' ?'selected':''}>IMDb-Bewertung</option>
|
||||
<option value="jahr_desc" ${_sort==='jahr_desc' ?'selected':''}>Neueste zuerst</option>
|
||||
<option value="jahr_asc" ${_sort==='jahr_asc' ?'selected':''}>Älteste zuerst</option>
|
||||
<option value="titel" ${_sort==='titel' ?'selected':''}>Titel A–Z</option>
|
||||
</select>
|
||||
<span id="movies-count" style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-grid" id="movie-grid"></div>
|
||||
`;
|
||||
|
|
@ -97,6 +127,31 @@ window.Page_movies = (() => {
|
|||
});
|
||||
});
|
||||
|
||||
content.querySelectorAll('.movies-type-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
_typ = btn.dataset.typ;
|
||||
content.querySelectorAll('.movies-type-btn').forEach(b => b.classList.remove('movies-filter-btn--active'));
|
||||
btn.classList.add('movies-filter-btn--active');
|
||||
const grid = content.querySelector('#movie-grid');
|
||||
grid.innerHTML = UI.skeleton(3);
|
||||
await _loadFilme();
|
||||
_renderMovieGrid(grid);
|
||||
});
|
||||
});
|
||||
|
||||
content.querySelector('#movies-search')?.addEventListener('input', e => {
|
||||
_search = e.target.value.trim().toLowerCase();
|
||||
_renderMovieGrid(content.querySelector('#movie-grid'));
|
||||
});
|
||||
|
||||
content.querySelector('#movies-sort')?.addEventListener('change', async e => {
|
||||
_sort = e.target.value;
|
||||
const grid = content.querySelector('#movie-grid');
|
||||
grid.innerHTML = UI.skeleton(3);
|
||||
await _loadFilme();
|
||||
_renderMovieGrid(grid);
|
||||
});
|
||||
|
||||
_renderMovieGrid(content.querySelector('#movie-grid'));
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +161,18 @@ window.Page_movies = (() => {
|
|||
|
||||
if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
|
||||
if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund);
|
||||
if (_filter === 'top') list = list.filter(f => f.bewertung_avg >= 4.0);
|
||||
if (_filter === 'top') list = list.filter(f => (f.imdb_rating || 0) >= 7.5 || f.bewertung_avg >= 4.0);
|
||||
if (_search) {
|
||||
list = list.filter(f =>
|
||||
(f.titel || '').toLowerCase().includes(_search) ||
|
||||
(f.hund_rasse || '').toLowerCase().includes(_search) ||
|
||||
(f.genre || '').toLowerCase().includes(_search) ||
|
||||
(f.beschreibung || '').toLowerCase().includes(_search)
|
||||
);
|
||||
}
|
||||
|
||||
const countEl = document.getElementById('movies-count');
|
||||
if (countEl) countEl.textContent = `${list.length} Einträge`;
|
||||
|
||||
if (list.length === 0) {
|
||||
grid.innerHTML = `<div style="grid-column:1/-1;padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">Keine Filme für diesen Filter.</div>`;
|
||||
|
|
@ -130,18 +196,25 @@ window.Page_movies = (() => {
|
|||
function _movieCard(film) {
|
||||
const stirbt = film.stirbt_der_hund;
|
||||
const tag = stirbt
|
||||
? `<div class="movie-tag-stirbt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> ACHTUNG: Der Hund stirbt</div>`
|
||||
: `<div class="movie-tag-ueberlebt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Der Hund überlebt</div>`;
|
||||
? `<div class="movie-tag-stirbt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</div>`
|
||||
: `<div class="movie-tag-ueberlebt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Hund überlebt</div>`;
|
||||
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false);
|
||||
const _ico = name => `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:middle"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
|
||||
const typLabel = film.typ === 'serie' ? `${_ico('list')} Serie` : film.typ === 'doku' ? `${_ico('camera')} Doku` : '';
|
||||
const imdb = film.imdb_rating ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">IMDb ${film.imdb_rating}</span>` : '';
|
||||
const streaming = film.streaming ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(film.streaming)}</span>` : '';
|
||||
|
||||
return `
|
||||
<div class="movie-card" data-film-id="${_esc(film.id)}">
|
||||
<div class="movie-card-emoji">${film.bild_emoji}</div>
|
||||
<div class="movie-card-body">
|
||||
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
|
||||
<div class="movie-card-genre">${_esc(film.genre)}</div>
|
||||
<div class="movie-card-genre" style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap">
|
||||
<span>${_esc(film.genre)}</span>${typLabel ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${typLabel}</span>` : ''}
|
||||
</div>
|
||||
<div class="movie-card-rasse"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${_esc(film.hund_rasse)}</div>
|
||||
${tag}
|
||||
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-1)">${imdb}${streaming}</div>
|
||||
<div class="movie-card-stars">${stars}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
708
backend/static/js/pages/playdate.js
Normal file
708
backend/static/js/pages/playdate.js
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Playdate-Matching
|
||||
Spielkameraden in der Nähe finden, Inserate verwalten, Anfragen
|
||||
============================================================ */
|
||||
|
||||
window.Page_playdate = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _activeTab = 'nearby'; // 'nearby' | 'listings' | 'requests'
|
||||
let _userPos = null;
|
||||
let _radius = 10;
|
||||
let _dogs = [];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------------
|
||||
function _esc(s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso.replace(' ', 'T'));
|
||||
return d.toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
function _dogAvatar(foto_url, name, size = 48) {
|
||||
const initials = _esc((name || '?').charAt(0).toUpperCase());
|
||||
if (foto_url) {
|
||||
return `<img src="${_esc(foto_url)}" alt="${initials}"
|
||||
style="width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;"
|
||||
onerror="this.outerHTML='<div style=\'width:${size}px;height:${size}px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;font-size:${Math.round(size*0.45)}px;font-weight:700;color:var(--c-primary);\'>${initials}</div>'">`;
|
||||
}
|
||||
return `<div style="width:${size}px;height:${size}px;border-radius:50%;
|
||||
background:var(--c-primary-subtle);display:flex;align-items:center;
|
||||
justify-content:center;font-size:${Math.round(size * 0.45)}px;
|
||||
font-weight:700;color:var(--c-primary);">${initials}</div>`;
|
||||
}
|
||||
|
||||
function _statusBadge(status) {
|
||||
const map = {
|
||||
pending: ['warning', 'Ausstehend'],
|
||||
accepted: ['success', 'Angenommen'],
|
||||
declined: ['danger', 'Abgelehnt'],
|
||||
};
|
||||
const [type, label] = map[status] || ['default', status];
|
||||
const colors = {
|
||||
warning: 'var(--c-warning, #f59e0b)',
|
||||
success: 'var(--c-success, #10b981)',
|
||||
danger: 'var(--c-danger, #ef4444)',
|
||||
default: 'var(--c-text-muted)',
|
||||
};
|
||||
return `<span style="font-size:var(--text-xs);font-weight:600;
|
||||
color:${colors[type]};padding:2px 8px;border-radius:999px;
|
||||
background:${colors[type]}18">${label}</span>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// INIT
|
||||
// ------------------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_dogs = appState.dogs?.filter(d => !d.is_guest) || [];
|
||||
_render();
|
||||
_switchTab(_activeTab);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
_dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
|
||||
_switchTab(_activeTab);
|
||||
}
|
||||
|
||||
function onDogChange() {
|
||||
_dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
|
||||
if (_activeTab === 'listings') _loadListings();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// RENDER — Grundstruktur mit Tabs
|
||||
// ------------------------------------------------------------------
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<div class="playdate-layout">
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="by-tabs" id="playdate-tabs" style="margin-bottom:var(--space-4)">
|
||||
<button class="by-tab active" data-tab="nearby">In der Nähe</button>
|
||||
<button class="by-tab" data-tab="listings">Meine Inserate</button>
|
||||
<button class="by-tab" data-tab="requests">
|
||||
Anfragen
|
||||
<span id="playdate-req-badge" style="display:none;margin-left:4px;
|
||||
background:var(--c-primary);color:#fff;border-radius:999px;
|
||||
padding:1px 6px;font-size:var(--text-xs);font-weight:700">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab-Inhalt -->
|
||||
<div id="playdate-content"></div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('playdate-tabs').addEventListener('click', e => {
|
||||
const btn = e.target.closest('.by-tab');
|
||||
if (!btn) return;
|
||||
_switchTab(btn.dataset.tab);
|
||||
});
|
||||
}
|
||||
|
||||
function _switchTab(tab) {
|
||||
_activeTab = tab;
|
||||
document.querySelectorAll('#playdate-tabs .by-tab').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.tab === tab);
|
||||
});
|
||||
const content = document.getElementById('playdate-content');
|
||||
if (!content) return;
|
||||
|
||||
if (tab === 'nearby') _renderNearby(content);
|
||||
if (tab === 'listings') _renderListings(content);
|
||||
if (tab === 'requests') _renderRequests(content);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB: IN DER NÄHE
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderNearby(el) {
|
||||
el.innerHTML = `
|
||||
<div>
|
||||
<!-- Toolbar: Radius-Auswahl + Standort-Button -->
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4);flex-wrap:wrap">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||||
${UI.icon('map-pin')}
|
||||
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)" id="nearby-location-label">
|
||||
${_userPos ? 'Standort bekannt' : 'Kein Standort'}
|
||||
</span>
|
||||
</div>
|
||||
<select id="nearby-radius" class="form-select" style="width:auto;font-size:var(--text-sm)">
|
||||
<option value="5" ${_radius===5 ? 'selected' : ''}>5 km</option>
|
||||
<option value="10" ${_radius===10 ? 'selected' : ''}>10 km</option>
|
||||
<option value="25" ${_radius===25 ? 'selected' : ''}>25 km</option>
|
||||
<option value="50" ${_radius===50 ? 'selected' : ''}>50 km</option>
|
||||
</select>
|
||||
<button class="btn btn-ghost btn-sm" id="nearby-locate-btn">
|
||||
${UI.icon('crosshair')} Standort aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info-Hinweis -->
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
|
||||
margin-bottom:var(--space-4);padding:var(--space-2) var(--space-3);
|
||||
background:var(--c-surface-2);border-radius:var(--radius-md)">
|
||||
${UI.icon('info')} Nur Hunde von Nutzern die sich ebenfalls mit einem Inserat eingetragen haben.
|
||||
Dein genauer Standort bleibt privat — es wird nur der Ortsname und die ungefähre Entfernung angezeigt.
|
||||
</div>
|
||||
|
||||
<!-- Ergebnisse -->
|
||||
<div id="nearby-results">
|
||||
<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">
|
||||
Standort wird ermittelt…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('nearby-radius').addEventListener('change', e => {
|
||||
_radius = parseInt(e.target.value, 10);
|
||||
_loadNearby();
|
||||
});
|
||||
|
||||
document.getElementById('nearby-locate-btn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('nearby-locate-btn');
|
||||
UI.setLoading(btn, true);
|
||||
try {
|
||||
_userPos = await API.getLocation();
|
||||
const label = document.getElementById('nearby-location-label');
|
||||
if (label) label.textContent = 'Standort aktualisiert';
|
||||
await _loadNearby();
|
||||
} catch {
|
||||
UI.toast.error('Standort konnte nicht ermittelt werden.');
|
||||
} finally {
|
||||
UI.setLoading(btn, false);
|
||||
}
|
||||
});
|
||||
|
||||
if (!_userPos) {
|
||||
try {
|
||||
_userPos = await API.getLocation();
|
||||
const label = document.getElementById('nearby-location-label');
|
||||
if (label) label.textContent = 'Standort bekannt';
|
||||
} catch {
|
||||
document.getElementById('nearby-results').innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-secondary)">
|
||||
${UI.icon('map-pin')}
|
||||
<p style="margin:var(--space-3) 0 var(--space-4)">
|
||||
Standort konnte nicht automatisch ermittelt werden.<br>
|
||||
Klicke auf "Standort aktualisieren".
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
await _loadNearby();
|
||||
}
|
||||
|
||||
async function _loadNearby() {
|
||||
if (!_userPos) return;
|
||||
const resultsEl = document.getElementById('nearby-results');
|
||||
if (!resultsEl) return;
|
||||
resultsEl.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Suche…</p>`;
|
||||
|
||||
try {
|
||||
const data = await API.get(`/playdate/nearby?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=${_radius}`);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
resultsEl.innerHTML = UI.emptyState({
|
||||
icon: UI.icon('paw-print'),
|
||||
title: 'Niemand in der Nähe',
|
||||
text: `Aktuell hat sich noch niemand in ${_radius} km Umkreis eingetragen. Trage deinen Hund unter "Meine Inserate" ein!`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
resultsEl.innerHTML = `
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3)">
|
||||
${data.map(d => _nearbyCard(d)).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultsEl.querySelectorAll('.playdate-anfrage-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const toDogId = parseInt(btn.dataset.dogId, 10);
|
||||
const dogName = btn.dataset.dogName;
|
||||
_showRequestModal(toDogId, dogName);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
resultsEl.innerHTML = `<p style="text-align:center;color:var(--c-danger);padding:var(--space-6)">${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _nearbyCard(d) {
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
|
||||
${_dogAvatar(d.foto_url, d.dog_name, 56)}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base);
|
||||
color:var(--c-text)">${_esc(d.dog_name)}</div>
|
||||
${d.rasse ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>` : ''}
|
||||
${d.alter ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.alter)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-3);flex-wrap:wrap">
|
||||
<span style="display:flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
${UI.icon('map-pin')}
|
||||
${d.ort_name ? _esc(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt
|
||||
</span>
|
||||
${d.geschlecht ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.geschlecht)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
${d.beschreibung ? `
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
margin:0 0 var(--space-3);line-height:1.5">
|
||||
${_esc(d.beschreibung)}
|
||||
</p>` : ''}
|
||||
|
||||
<button class="btn btn-primary btn-sm playdate-anfrage-btn"
|
||||
data-dog-id="${d.dog_id}"
|
||||
data-dog-name="${_esc(d.dog_name)}">
|
||||
${UI.icon('paw-print')} Spielkamerad anfragen
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _showRequestModal(toDogId, dogName) {
|
||||
const formId = 'playdate-req-form';
|
||||
UI.modal.open({
|
||||
title: `Anfrage an ${dogName}`,
|
||||
body: `
|
||||
<form id="${formId}">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nachricht (optional)</label>
|
||||
<textarea id="req-nachricht" class="form-control" rows="3" maxlength="500"
|
||||
placeholder="Hallo! Unsere Hunde könnten super zusammenpassen…"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" id="req-cancel-btn">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="req-send-btn" form="${formId}">
|
||||
${UI.icon('paper-plane-tilt')} Anfrage senden
|
||||
</button>
|
||||
`,
|
||||
});
|
||||
|
||||
document.getElementById('req-cancel-btn').addEventListener('click', () => UI.modal.close());
|
||||
document.getElementById('req-send-btn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('req-send-btn');
|
||||
const nachricht = document.getElementById('req-nachricht').value.trim();
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const result = await API.post('/playdate/request', {
|
||||
to_dog_id: toDogId,
|
||||
nachricht: nachricht || null,
|
||||
});
|
||||
UI.modal.close();
|
||||
UI.toast.success('Anfrage gesendet! Ein Chat wurde geöffnet.');
|
||||
// Zum Chat navigieren
|
||||
if (result.conversation_id) {
|
||||
setTimeout(() => {
|
||||
App.navigate('chat', true, { conversation_id: result.conversation_id });
|
||||
}, 800);
|
||||
}
|
||||
}, { errorMsg: null });
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB: MEINE INSERATE
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderListings(el) {
|
||||
el.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Lädt…</p>`;
|
||||
await _loadListings(el);
|
||||
}
|
||||
|
||||
async function _loadListings(el) {
|
||||
const target = el || document.getElementById('playdate-content');
|
||||
if (!target) return;
|
||||
|
||||
if (_dogs.length === 0) {
|
||||
target.innerHTML = UI.emptyState({
|
||||
icon: UI.icon('paw-print'),
|
||||
title: 'Noch kein Hund',
|
||||
text: 'Lege zuerst einen Hund in deinem Profil an.',
|
||||
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Hund anlegen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Listings für alle eigenen Hunde laden
|
||||
const listings = {};
|
||||
await Promise.all(_dogs.map(async dog => {
|
||||
try {
|
||||
const data = await API.get(`/playdate/my-listing/${dog.id}`);
|
||||
listings[dog.id] = data;
|
||||
} catch {
|
||||
listings[dog.id] = null;
|
||||
}
|
||||
}));
|
||||
|
||||
target.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||||
${_dogs.map(dog => _listingCard(dog, listings[dog.id])).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event-Delegation für alle Buttons
|
||||
target.addEventListener('click', async e => {
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
const dogId = parseInt(btn.dataset.dogId, 10);
|
||||
const dog = _dogs.find(d => d.id === dogId);
|
||||
|
||||
if (action === 'edit') {
|
||||
_showListingModal(dog, listings[dogId], async () => {
|
||||
await _loadListings();
|
||||
});
|
||||
}
|
||||
if (action === 'deactivate') {
|
||||
if (!window.confirm(`Inserat für ${dog?.name} deaktivieren?`)) return;
|
||||
try {
|
||||
await API.del(`/playdate/listing/${dogId}`);
|
||||
UI.toast.success('Inserat deaktiviert.');
|
||||
await _loadListings();
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _listingCard(dog, listing) {
|
||||
const isAktiv = listing && listing.aktiv;
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div style="display:flex;gap:var(--space-3);align-items:center;margin-bottom:var(--space-3)">
|
||||
${_dogAvatar(dog.foto_url, dog.name, 44)}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(dog.name)}</div>
|
||||
${dog.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(dog.rasse)}</div>` : ''}
|
||||
</div>
|
||||
<span style="font-size:var(--text-xs);font-weight:600;
|
||||
padding:2px 10px;border-radius:999px;
|
||||
background:${isAktiv ? 'var(--c-success-subtle,#d1fae5)' : 'var(--c-surface-2)'};
|
||||
color:${isAktiv ? 'var(--c-success,#10b981)' : 'var(--c-text-muted)'}">
|
||||
${isAktiv ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${isAktiv ? `
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
|
||||
${UI.icon('map-pin')}
|
||||
${listing.ort_name ? _esc(listing.ort_name) + ' · ' : ''}
|
||||
Radius: ${listing.radius_km} km
|
||||
</div>
|
||||
${listing.beschreibung ? `
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
margin:0 0 var(--space-3);line-height:1.5">${_esc(listing.beschreibung)}</p>` : ''}
|
||||
` : `
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0 0 var(--space-3)">
|
||||
Noch kein Inserat — trage dich ein, damit andere dich finden können.
|
||||
</p>
|
||||
`}
|
||||
|
||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm"
|
||||
data-action="edit" data-dog-id="${dog.id}">
|
||||
${UI.icon('pencil')} ${isAktiv ? 'Bearbeiten' : 'Inserat anlegen'}
|
||||
</button>
|
||||
${isAktiv ? `
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
data-action="deactivate" data-dog-id="${dog.id}">
|
||||
${UI.icon('x')} Deaktivieren
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _showListingModal(dog, existing, onSaved) {
|
||||
const formId = 'listing-form';
|
||||
UI.modal.open({
|
||||
title: `Inserat für ${dog.name}`,
|
||||
body: `
|
||||
<form id="${formId}">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Ort / Standort</label>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<input type="text" id="listing-ort" class="form-control"
|
||||
placeholder="z.B. München"
|
||||
value="${_esc(existing?.ort_name || '')}">
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="listing-gps-btn"
|
||||
title="GPS-Standort ermitteln">
|
||||
${UI.icon('crosshair')}
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="listing-lat" value="${existing?.lat || ''}">
|
||||
<input type="hidden" id="listing-lon" value="${existing?.lon || ''}">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
|
||||
Tipp: Klicke auf ${UI.icon('crosshair')} um deinen Ortsnamen automatisch zu ermitteln.
|
||||
Nur der Ortsname wird für andere sichtbar — nicht dein genauer Standort.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Suchradius</label>
|
||||
<select id="listing-radius" class="form-control">
|
||||
<option value="5" ${(existing?.radius_km||10)===5 ? 'selected' : ''}>5 km</option>
|
||||
<option value="10" ${(existing?.radius_km||10)===10 ? 'selected' : ''}>10 km</option>
|
||||
<option value="25" ${(existing?.radius_km||10)===25 ? 'selected' : ''}>25 km</option>
|
||||
<option value="50" ${(existing?.radius_km||10)===50 ? 'selected' : ''}>50 km</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Beschreibung (optional)</label>
|
||||
<textarea id="listing-beschreibung" class="form-control" rows="3" maxlength="400"
|
||||
placeholder="Erzähl etwas über deinen Hund und was ihr sucht…">${_esc(existing?.beschreibung || '')}</textarea>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" id="listing-cancel-btn">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="listing-save-btn">
|
||||
${UI.icon('floppy-disk')} Speichern
|
||||
</button>
|
||||
`,
|
||||
});
|
||||
|
||||
// GPS-Button
|
||||
document.getElementById('listing-gps-btn').addEventListener('click', async () => {
|
||||
const gpsBtn = document.getElementById('listing-gps-btn');
|
||||
UI.setLoading(gpsBtn, true);
|
||||
try {
|
||||
const pos = await API.getLocation();
|
||||
document.getElementById('listing-lat').value = pos.lat;
|
||||
document.getElementById('listing-lon').value = pos.lon;
|
||||
|
||||
// Reverse-Geocoding für Ortsname
|
||||
try {
|
||||
const rev = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${pos.lat}&lon=${pos.lon}&format=json&zoom=10&accept-language=de`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
const geoData = await rev.json();
|
||||
const a = geoData.address || {};
|
||||
const ort = a.city || a.town || a.village || a.municipality || '';
|
||||
if (ort) document.getElementById('listing-ort').value = ort;
|
||||
} catch {}
|
||||
UI.toast.success('Standort ermittelt.');
|
||||
} catch {
|
||||
UI.toast.error('Standort konnte nicht ermittelt werden.');
|
||||
} finally {
|
||||
UI.setLoading(gpsBtn, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('listing-cancel-btn').addEventListener('click', () => UI.modal.close());
|
||||
|
||||
document.getElementById('listing-save-btn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('listing-save-btn');
|
||||
const lat = parseFloat(document.getElementById('listing-lat').value);
|
||||
const lon = parseFloat(document.getElementById('listing-lon').value);
|
||||
const ort = document.getElementById('listing-ort').value.trim();
|
||||
const rad = parseInt(document.getElementById('listing-radius').value, 10);
|
||||
const desc = document.getElementById('listing-beschreibung').value.trim();
|
||||
|
||||
if (!lat || !lon) {
|
||||
UI.toast.error('Bitte ermittle zuerst deinen Standort über den GPS-Button.');
|
||||
return;
|
||||
}
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
await API.put('/playdate/listing', {
|
||||
dog_id: dog.id,
|
||||
lat,
|
||||
lon,
|
||||
ort_name: ort || null,
|
||||
radius_km: rad,
|
||||
beschreibung: desc || null,
|
||||
});
|
||||
UI.modal.close();
|
||||
UI.toast.success('Inserat gespeichert!');
|
||||
onSaved?.();
|
||||
}, { errorMsg: null });
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB: ANFRAGEN
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderRequests(el) {
|
||||
el.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Lädt…</p>`;
|
||||
try {
|
||||
const data = await API.get('/playdate/requests');
|
||||
const incoming = data.incoming || [];
|
||||
const outgoing = data.outgoing || [];
|
||||
|
||||
// Badge aktualisieren
|
||||
const pendingCount = incoming.filter(r => r.status === 'pending').length;
|
||||
const badge = document.getElementById('playdate-req-badge');
|
||||
if (badge) {
|
||||
badge.textContent = pendingCount;
|
||||
badge.style.display = pendingCount > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
if (incoming.length === 0 && outgoing.length === 0) {
|
||||
el.innerHTML = UI.emptyState({
|
||||
icon: UI.icon('paw-print'),
|
||||
title: 'Noch keine Anfragen',
|
||||
text: 'Wenn du oder jemand anderes eine Playdate-Anfrage schickt, erscheint sie hier.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
|
||||
|
||||
${incoming.length > 0 ? `
|
||||
<div>
|
||||
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
|
||||
margin:0 0 var(--space-3)">Eingehende Anfragen</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
${incoming.map(r => _incomingCard(r)).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
${outgoing.length > 0 ? `
|
||||
<div>
|
||||
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
|
||||
margin:0 0 var(--space-3)">Ausgehende Anfragen</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
${outgoing.map(r => _outgoingCard(r)).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Button-Events (Accept/Decline)
|
||||
el.querySelectorAll('.req-accept-btn, .req-decline-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const reqId = parseInt(btn.dataset.reqId, 10);
|
||||
const status = btn.dataset.status;
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const result = await API.patch(`/playdate/requests/${reqId}`, { status });
|
||||
if (status === 'accepted' && result.conversation_id) {
|
||||
UI.toast.success('Anfrage angenommen! Chat wurde geöffnet.');
|
||||
setTimeout(() => {
|
||||
App.navigate('chat', true, { conversation_id: result.conversation_id });
|
||||
}, 800);
|
||||
} else {
|
||||
UI.toast.success(status === 'accepted' ? 'Anfrage angenommen.' : 'Anfrage abgelehnt.');
|
||||
}
|
||||
await _renderRequests(el);
|
||||
}, { errorMsg: null });
|
||||
});
|
||||
});
|
||||
|
||||
// Chat-Buttons
|
||||
el.querySelectorAll('.req-chat-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
App.navigate('chat', true);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
el.innerHTML = `<p style="text-align:center;color:var(--c-danger);padding:var(--space-6)">${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _incomingCard(r) {
|
||||
const isPending = r.status === 'pending';
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
|
||||
${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.from_dog_name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''}
|
||||
${r.alter ? _esc(r.alter) + ' · ' : ''}
|
||||
von ${_esc(r.from_user_name)}
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
|
||||
</div>
|
||||
${_statusBadge(r.status)}
|
||||
</div>
|
||||
|
||||
${r.nachricht ? `
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-3);
|
||||
line-height:1.5">
|
||||
"${_esc(r.nachricht)}"
|
||||
</div>` : ''}
|
||||
|
||||
${isPending ? `
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<button class="btn btn-primary btn-sm req-accept-btn"
|
||||
data-req-id="${r.id}" data-status="accepted">
|
||||
${UI.icon('check')} Annehmen
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm req-decline-btn"
|
||||
data-req-id="${r.id}" data-status="declined">
|
||||
${UI.icon('x')} Ablehnen
|
||||
</button>
|
||||
</div>` : `
|
||||
${r.status === 'accepted' ? `
|
||||
<button class="btn btn-ghost btn-sm req-chat-btn">
|
||||
${UI.icon('chat-circle-dots')} Zum Chat
|
||||
</button>` : ''}
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _outgoingCard(r) {
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
|
||||
${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.to_dog_name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''}
|
||||
von ${_esc(r.to_user_name)}
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
|
||||
</div>
|
||||
${_statusBadge(r.status)}
|
||||
</div>
|
||||
|
||||
${r.nachricht ? `
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
|
||||
"${_esc(r.nachricht)}"
|
||||
</p>` : ''}
|
||||
|
||||
${r.status === 'accepted' ? `
|
||||
<button class="btn btn-ghost btn-sm req-chat-btn">
|
||||
${UI.icon('chat-circle-dots')} Chat öffnen
|
||||
</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
return { init, refresh, onDogChange };
|
||||
})();
|
||||
188
backend/static/js/pages/recalls.js
Normal file
188
backend/static/js/pages/recalls.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Tierfutter-Rückrufe
|
||||
Seiten-Modul: RASFF EU Rückruf-Alarm für Heimtierfutter.
|
||||
============================================================ */
|
||||
|
||||
window.Page_recalls = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MODUL-STATE
|
||||
// ----------------------------------------------------------
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _recalls = [];
|
||||
let _query = '';
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_query = '';
|
||||
await _render();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// REFRESH
|
||||
// ----------------------------------------------------------
|
||||
async function refresh() {
|
||||
_recalls = [];
|
||||
_query = '';
|
||||
await _render();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RENDER
|
||||
// ----------------------------------------------------------
|
||||
async function _render() {
|
||||
_container.innerHTML = `
|
||||
<!-- Warnbanner -->
|
||||
<div class="recalls-warning-banner">
|
||||
<svg class="ph-icon recalls-warning-icon" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#warning"></use>
|
||||
</svg>
|
||||
<p class="recalls-warning-text">
|
||||
<strong>Hinweis:</strong> Prüfe immer das Mindesthaltbarkeitsdatum und die Chargen-Nummer
|
||||
bevor du ein gemeldetes Produkt entsorgst oder zurückgibst.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Suchfeld -->
|
||||
<div style="position:relative;margin-bottom:var(--space-4)">
|
||||
<svg class="ph-icon" aria-hidden="true"
|
||||
style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
|
||||
color:var(--c-text-muted);pointer-events:none">
|
||||
<use href="/icons/phosphor.svg#magnifying-glass"></use>
|
||||
</svg>
|
||||
<input type="search" id="recalls-search" placeholder="Produkt, Gefahr oder Herkunft suchen…"
|
||||
value="${UI.escape(_query)}"
|
||||
style="width:100%;padding:var(--space-2) var(--space-3) var(--space-2) calc(var(--space-3)*2 + 1.2rem);
|
||||
border:1px solid var(--c-border);border-radius:var(--radius-md);
|
||||
font-size:var(--text-sm);background:var(--c-surface);color:var(--c-text);
|
||||
box-sizing:border-box">
|
||||
</div>
|
||||
|
||||
<!-- Ergebnis-Liste -->
|
||||
<div id="recalls-list">${UI.skeleton(4)}</div>
|
||||
`;
|
||||
|
||||
// Suchfeld-Handler
|
||||
_container.querySelector('#recalls-search').addEventListener('input', (e) => {
|
||||
_query = e.target.value.trim();
|
||||
_renderList();
|
||||
});
|
||||
|
||||
await _loadRecalls();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// DATEN LADEN
|
||||
// ----------------------------------------------------------
|
||||
async function _loadRecalls() {
|
||||
try {
|
||||
const url = _query ? `/recalls?q=${encodeURIComponent(_query)}` : '/recalls';
|
||||
_recalls = await API.get(url);
|
||||
} catch {
|
||||
_container.querySelector('#recalls-list').innerHTML = UI.emptyState({
|
||||
icon: 'warning-circle',
|
||||
title: 'Rückrufe konnten nicht geladen werden',
|
||||
text: 'Bitte versuche es später erneut.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
_renderList();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// LISTE RENDERN
|
||||
// ----------------------------------------------------------
|
||||
function _renderList() {
|
||||
const listEl = _container.querySelector('#recalls-list');
|
||||
if (!listEl) return;
|
||||
|
||||
const filtered = _query
|
||||
? _recalls.filter(r => {
|
||||
const q = _query.toLowerCase();
|
||||
return (r.titel || '').toLowerCase().includes(q)
|
||||
|| (r.produkt || '').toLowerCase().includes(q)
|
||||
|| (r.gefahr || '').toLowerCase().includes(q)
|
||||
|| (r.herkunft || '').toLowerCase().includes(q);
|
||||
})
|
||||
: _recalls;
|
||||
|
||||
if (!filtered.length) {
|
||||
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
listEl.innerHTML = UI.emptyState({
|
||||
icon: UI.icon('check-circle'),
|
||||
title: 'Aktuell keine Rückrufe',
|
||||
text: `Letzte Prüfung: ${today}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = filtered.map(r => _cardHtml(r)).join('');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// EINZELNE KARTE
|
||||
// ----------------------------------------------------------
|
||||
function _cardHtml(r) {
|
||||
const datum = r.datum
|
||||
? new Date(r.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: '';
|
||||
|
||||
const meta = [
|
||||
r.herkunft ? `<span>${UI.icon('globe-hemisphere-west')} ${UI.escape(r.herkunft)}</span>` : '',
|
||||
datum ? `<span>${UI.icon('calendar-blank')} ${datum}</span>` : '',
|
||||
r.quelle ? `<span style="text-transform:uppercase;font-size:var(--text-xs);color:var(--c-text-muted)">${UI.escape(r.quelle)}</span>` : '',
|
||||
].filter(Boolean).join('<span style="color:var(--c-border)"> · </span>');
|
||||
|
||||
const linkHtml = r.url
|
||||
? `<a href="${UI.escape(r.url)}" target="_blank" rel="noopener"
|
||||
style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-sm);
|
||||
color:var(--c-primary);text-decoration:none;margin-top:var(--space-1)">
|
||||
${UI.icon('arrow-square-out')} Details auf RASFF
|
||||
</a>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div style="background:var(--c-surface);border:1px solid var(--c-border);
|
||||
border-left:4px solid #dc2626;border-radius:var(--radius-md);
|
||||
padding:var(--space-3) var(--space-4);margin-bottom:var(--space-3)">
|
||||
<!-- Titel -->
|
||||
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-1)">
|
||||
<svg class="ph-icon" aria-hidden="true" style="color:#dc2626;flex-shrink:0;margin-top:2px">
|
||||
<use href="/icons/phosphor.svg#warning-octagon"></use>
|
||||
</svg>
|
||||
<strong style="font-size:var(--text-base);color:var(--c-text);line-height:1.4">
|
||||
${UI.escape(r.produkt || r.titel)}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<!-- Gefahr -->
|
||||
${r.gefahr ? `
|
||||
<p style="margin:0 0 var(--space-2) 0;font-size:var(--text-sm);color:var(--c-text-muted);
|
||||
padding-left:calc(var(--space-2) + 1.2rem)">
|
||||
${UI.escape(r.gefahr)}
|
||||
</p>` : ''}
|
||||
|
||||
<!-- Meta-Zeile -->
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);
|
||||
font-size:var(--text-sm);color:var(--c-text-muted);
|
||||
padding-left:calc(var(--space-2) + 1.2rem)">
|
||||
${meta}
|
||||
</div>
|
||||
|
||||
<!-- Link -->
|
||||
${linkHtml ? `<div style="padding-left:calc(var(--space-2) + 1.2rem)">${linkHtml}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC API
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
|
||||
})();
|
||||
|
|
@ -263,6 +263,12 @@ window.Page_settings = (() => {
|
|||
<span>Kalender abonnieren</span>
|
||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||||
</div>
|
||||
<div class="sidebar-item" id="settings-worlds-btn"
|
||||
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#squares-four"></use></svg>
|
||||
<span>Welten einrichten</span>
|
||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||||
</div>
|
||||
<div class="sidebar-item" id="settings-logout-btn"
|
||||
style="padding:var(--space-4);border-radius:0;cursor:pointer;
|
||||
color:var(--c-danger)">
|
||||
|
|
@ -653,6 +659,11 @@ window.Page_settings = (() => {
|
|||
}
|
||||
});
|
||||
|
||||
document.getElementById('settings-worlds-btn')?.addEventListener('click', () => {
|
||||
if (window.Worlds?._openConfigModal) window.Worlds._openConfigModal();
|
||||
else if (window.Worlds) window.Worlds.openConfig?.();
|
||||
});
|
||||
|
||||
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
title : 'Abmelden?',
|
||||
|
|
@ -1238,6 +1249,49 @@ window.Page_settings = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// NICHT EINGELOGGT — Login / Registrierung
|
||||
// ----------------------------------------------------------
|
||||
function _renderVerifyPending(email) {
|
||||
_container.innerHTML = `
|
||||
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0;text-align:center">
|
||||
<div style="margin-bottom:var(--space-5)">
|
||||
<img src="/icons/icon-180.png" alt="Ban Yaro"
|
||||
style="width:72px;height:72px;border-radius:var(--radius-lg);margin-bottom:var(--space-3)">
|
||||
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">E-Mail bestätigen</h1>
|
||||
</div>
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-lg);
|
||||
padding:var(--space-5);margin-bottom:var(--space-4);text-align:left">
|
||||
<p style="margin:0 0 var(--space-2)">
|
||||
Wir haben einen Bestätigungslink an<br>
|
||||
<strong>${email}</strong><br>
|
||||
gesendet.
|
||||
</p>
|
||||
<p style="margin:0;color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||||
Bitte prüfe dein Postfach und klicke auf den Link, um dein Konto zu aktivieren.
|
||||
Danach kannst du dich hier anmelden.
|
||||
</p>
|
||||
</div>
|
||||
<button id="verify-resend-btn2" class="btn btn-ghost w-full"
|
||||
style="margin-bottom:var(--space-3)">
|
||||
Link erneut senden
|
||||
</button>
|
||||
<button id="verify-back-btn" class="btn btn-ghost w-full"
|
||||
style="color:var(--c-text-muted);font-size:var(--text-sm)">
|
||||
Anderes Konto / Anmelden
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('verify-resend-btn2')?.addEventListener('click', async function() {
|
||||
this.disabled = true;
|
||||
this.textContent = 'Gesendet …';
|
||||
try {
|
||||
await API.post('/auth/resend-verification', { email });
|
||||
UI.toast.success('Bestätigungs-Mail erneut gesendet.');
|
||||
} catch {
|
||||
UI.toast.error('Fehler beim Senden — bitte versuche es später erneut.');
|
||||
}
|
||||
});
|
||||
document.getElementById('verify-back-btn')?.addEventListener('click', () => _renderAuth('login'));
|
||||
}
|
||||
|
||||
function _renderAuth(mode) {
|
||||
// Passwort-Reset über Link aus E-Mail
|
||||
const resetToken = sessionStorage.getItem('by_reset_token');
|
||||
|
|
@ -1467,7 +1521,16 @@ window.Page_settings = (() => {
|
|||
const fd = UI.formData(e.target);
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const result = await API.auth.login(fd.email, fd.password);
|
||||
let result;
|
||||
try {
|
||||
result = await API.auth.login(fd.email, fd.password);
|
||||
} catch (err) {
|
||||
if (err.message === 'EMAIL_NOT_VERIFIED') {
|
||||
_renderVerifyPending(fd.email);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
localStorage.setItem('by_token', result.token);
|
||||
|
||||
// User-Daten laden
|
||||
|
|
@ -1583,22 +1646,12 @@ window.Page_settings = (() => {
|
|||
const refCode = sessionStorage.getItem('by_ref_code') || '';
|
||||
const finalCode = partnerCode || refCode || undefined;
|
||||
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
|
||||
localStorage.setItem('by_token', result.token);
|
||||
if (refCode) sessionStorage.removeItem('by_ref_code');
|
||||
|
||||
_appState.user = await API.auth.me();
|
||||
document.getElementById('sidebar-username').textContent = _appState.user.name;
|
||||
_appState.dogs = [];
|
||||
_appState.activeDog = null;
|
||||
|
||||
document.getElementById('header-login-btn')?.remove();
|
||||
const greeting = _appState.user.is_founder_pending
|
||||
? `Willkommen, ${_appState.user.name}! 🎉 Dein Gründer-Platz ist reserviert — leg jetzt dein Hunde-Profil an um ihn zu sichern!`
|
||||
: _appState.user.is_founder
|
||||
? `Willkommen, Gründer ${_appState.user.name}! 🎉`
|
||||
: `Willkommen bei Ban Yaro, ${_appState.user.name}!`;
|
||||
UI.toast.success(greeting);
|
||||
App.showOnboarding();
|
||||
if (result.pending_verification) {
|
||||
_renderVerifyPending(fd.email);
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1747,6 +1747,16 @@ window.Page_uebungen = (() => {
|
|||
_closeModal();
|
||||
if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; }
|
||||
|
||||
// Streak aktualisieren — fire-and-forget, Toast bei neuem Streak-Rekord
|
||||
API.post(`/streak/${body.dog_id}/ping`).then(streak => {
|
||||
if (!streak) return;
|
||||
if (streak.current_streak > 1 && streak.current_streak === streak.longest_streak) {
|
||||
setTimeout(() => UI.toast.success(`🔥 Neuer Rekord! ${streak.current_streak} Tage in Folge trainiert!`), 1500);
|
||||
} else if (streak.current_streak > 1) {
|
||||
setTimeout(() => UI.toast.info(`🔥 Streak: ${streak.current_streak} Tage in Folge!`), 1500);
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
if (resp.ist_top) {
|
||||
UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.');
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -463,6 +463,8 @@ window.Page_welcome = (() => {
|
|||
`).join('')}
|
||||
</div>
|
||||
|
||||
${dog?.id ? `<div id="wc-streak-widget"></div>` : ''}
|
||||
|
||||
<h2 class="wc-section-title" style="margin-top:var(--space-6)">Mehr entdecken</h2>
|
||||
<div class="wc-grid">
|
||||
${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => `
|
||||
|
|
@ -497,9 +499,85 @@ window.Page_welcome = (() => {
|
|||
_updateChipsFromDash(dash);
|
||||
_tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen
|
||||
}).catch(() => { /* Skeleton bleibt sichtbar */ });
|
||||
|
||||
// Streak-Widget asynchron laden
|
||||
_loadStreakWidget(dog.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// STREAK-WIDGET
|
||||
// ----------------------------------------------------------
|
||||
async function _loadStreakWidget(dogId) {
|
||||
const slot = _container.querySelector('#wc-streak-widget');
|
||||
if (!slot) return;
|
||||
|
||||
let streak;
|
||||
try {
|
||||
streak = await API.get(`/streak/${dogId}`);
|
||||
} catch { return; }
|
||||
|
||||
if (!streak || (streak.current_streak === 0 && streak.longest_streak === 0)) return;
|
||||
|
||||
slot.innerHTML = _streakWidgetHTML(streak);
|
||||
|
||||
slot.querySelector('#wc-streak-leaderboard-btn')?.addEventListener('click', async () => {
|
||||
const modalEl = UI.modal.open({
|
||||
title: '🔥 Trainings-Bestenliste',
|
||||
body: '<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-4)">Wird geladen…</p>',
|
||||
});
|
||||
let board;
|
||||
try { board = await API.get('/streak/leaderboard'); } catch { board = []; }
|
||||
const bodyEl = modalEl?.querySelector('.modal-body');
|
||||
if (bodyEl) bodyEl.innerHTML = _leaderboardHTML(board);
|
||||
});
|
||||
}
|
||||
|
||||
function _streakWidgetHTML(s) {
|
||||
const cur = s.current_streak || 0;
|
||||
const best = s.longest_streak || 0;
|
||||
return `
|
||||
<div class="wc-streak-card">
|
||||
<div class="wc-streak-flame-wrap">
|
||||
<span class="wc-streak-flame">🔥</span>
|
||||
<span class="wc-streak-number">${cur}</span>
|
||||
</div>
|
||||
<div class="wc-streak-info">
|
||||
<div class="wc-streak-label">Tage in Folge trainiert</div>
|
||||
<div class="wc-streak-best">Rekord: ${best} ${best === 1 ? 'Tag' : 'Tage'}</div>
|
||||
</div>
|
||||
<button class="wc-streak-lb-btn" id="wc-streak-leaderboard-btn" title="Bestenliste">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _leaderboardHTML(rows) {
|
||||
if (!rows || !rows.length) {
|
||||
return '<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Einträge.</p>';
|
||||
}
|
||||
const medals = ['🥇', '🥈', '🥉'];
|
||||
return `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
${rows.map((r, i) => `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-subtle)">
|
||||
<span style="font-size:1.4rem;width:28px;text-align:center;flex-shrink:0">${medals[i] || (i + 1) + '.'}</span>
|
||||
${r.foto_url
|
||||
? `<img src="${UI.escape(r.foto_url)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;flex-shrink:0" alt="">`
|
||||
: `<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary-subtle);flex-shrink:0"></div>`}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${UI.escape(r.dog_name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(r.rasse || '')}${r.user_name ? ' · ' + UI.escape(r.user_name) : ''}</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||
<span style="font-size:1.1rem">🔥</span>
|
||||
<span style="font-weight:var(--weight-bold);font-size:var(--text-base);color:var(--c-primary)">${r.current_streak}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _updateHeroFromDash(dash, dog) {
|
||||
const heroBox = _container.querySelector('#wc-hero-box');
|
||||
if (!heroBox) return;
|
||||
|
|
|
|||
581
backend/static/js/pages/wetter.js
Normal file
581
backend/static/js/pages/wetter.js
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Wetter (7-Tage-Wettervorhersage)
|
||||
Seiten-Modul: Hunde-optimierte Wettervorhersage mit GPS.
|
||||
============================================================ */
|
||||
|
||||
window.Page_wetter = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// KONSTANTEN
|
||||
// ----------------------------------------------------------
|
||||
// WMO-Code → Phosphor-Icon-Name (aus Sprite)
|
||||
const WMO_ICON = {
|
||||
0:'sun', 1:'sun-dim', 2:'cloud-sun', 3:'cloud',
|
||||
45:'cloud-fog', 48:'cloud-fog',
|
||||
51:'cloud-rain', 53:'cloud-rain', 55:'cloud-rain',
|
||||
61:'cloud-rain', 63:'cloud-rain', 65:'cloud-rain',
|
||||
71:'cloud-snow', 73:'cloud-snow', 75:'cloud-snow', 77:'snowflake',
|
||||
80:'rainbow-cloud', 81:'cloud-rain', 82:'cloud-rain',
|
||||
85:'cloud-snow', 86:'cloud-snow',
|
||||
95:'cloud-lightning', 96:'cloud-lightning', 99:'cloud-lightning',
|
||||
};
|
||||
// Farben passend zum Wetter (für Icon-Tinting)
|
||||
const WMO_COLOR = {
|
||||
0:'#F59E0B', 1:'#F59E0B', 2:'#94A3B8', 3:'#64748B',
|
||||
45:'#94A3B8', 48:'#94A3B8',
|
||||
51:'#60A5FA', 53:'#3B82F6', 55:'#2563EB',
|
||||
61:'#3B82F6', 63:'#2563EB', 65:'#1D4ED8',
|
||||
71:'#BAE6FD', 73:'#7DD3FC', 75:'#38BDF8', 77:'#BAE6FD',
|
||||
80:'#60A5FA', 81:'#3B82F6', 82:'#2563EB',
|
||||
85:'#7DD3FC', 86:'#38BDF8',
|
||||
95:'#7C3AED', 96:'#6D28D9', 99:'#5B21B6',
|
||||
};
|
||||
function _wmoIcon(code, size = '2rem', extraStyle = '') {
|
||||
const name = WMO_ICON[code] || 'cloud';
|
||||
const color = WMO_COLOR[code] || 'var(--c-text-secondary)';
|
||||
return `<svg class="ph-icon" aria-hidden="true"
|
||||
style="width:${size};height:${size};color:${color};flex-shrink:0;${extraStyle}">
|
||||
<use href="/icons/phosphor.svg#${name}"></use>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const WMO_DESC = {
|
||||
0:'Klarer Himmel', 1:'Überwiegend klar', 2:'Teilweise bewölkt', 3:'Bedeckt',
|
||||
45:'Nebel', 48:'Gefrierender Nebel',
|
||||
51:'Leichter Sprühregen', 53:'Mäßiger Sprühregen', 55:'Starker Sprühregen',
|
||||
61:'Leichter Regen', 63:'Mäßiger Regen', 65:'Starker Regen',
|
||||
71:'Leichter Schneefall', 73:'Mäßiger Schneefall', 75:'Starker Schneefall', 77:'Schneekörner',
|
||||
80:'Leichte Regenschauer', 81:'Mäßige Regenschauer', 82:'Starke Regenschauer',
|
||||
85:'Leichte Schneeschauer', 86:'Starke Schneeschauer',
|
||||
95:'Gewitter', 96:'Gewitter mit leichtem Hagel', 99:'Gewitter mit starkem Hagel'
|
||||
};
|
||||
|
||||
const DAY_NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MODUL-STATE
|
||||
// ----------------------------------------------------------
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _data = null;
|
||||
let _selDay = 0;
|
||||
let _loading = false;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_selDay = 0;
|
||||
_renderShell();
|
||||
_tryAutoLocate();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// REFRESH
|
||||
// ----------------------------------------------------------
|
||||
async function refresh() {
|
||||
_selDay = 0;
|
||||
_renderShell();
|
||||
_tryAutoLocate();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RENDER — Grundstruktur
|
||||
// ----------------------------------------------------------
|
||||
function _renderShell() {
|
||||
_container.innerHTML = `
|
||||
<div id="wttr-body">
|
||||
<div id="wttr-locating" style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||||
<div style="margin-bottom:var(--space-3)">${_wmoIcon(2, '2.5rem')}</div>
|
||||
<p style="color:var(--c-text-secondary)">Standort wird ermittelt…</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// STANDORT AUTOMATISCH ERMITTELN
|
||||
// ----------------------------------------------------------
|
||||
async function _tryAutoLocate() {
|
||||
try {
|
||||
const pos = await API.getLocation({ timeout: 8000, maximumAge: 300_000 });
|
||||
await _loadData(pos.lat, pos.lon);
|
||||
} catch {
|
||||
_showLocationError();
|
||||
}
|
||||
}
|
||||
|
||||
function _showLocationError() {
|
||||
const body = _container.querySelector('#wttr-body');
|
||||
if (!body) return;
|
||||
body.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||||
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">📍</div>
|
||||
<h3 style="margin-bottom:var(--space-2)">Standort nicht verfügbar</h3>
|
||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:300px;margin-inline:auto">
|
||||
Bitte erlaube den Zugriff auf deinen Standort, um die Wettervorhersage zu laden.
|
||||
</p>
|
||||
<button class="btn btn-primary" id="wttr-btn-retry">
|
||||
${UI.icon('map-pin')} Nochmal versuchen
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
body.querySelector('#wttr-btn-retry')?.addEventListener('click', () => {
|
||||
_renderShell();
|
||||
_tryAutoLocate();
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// DATEN LADEN
|
||||
// ----------------------------------------------------------
|
||||
async function _loadData(lat, lon) {
|
||||
if (_loading) return;
|
||||
_loading = true;
|
||||
try {
|
||||
_data = await API.weather.forecast(lat, lon);
|
||||
_selDay = 0;
|
||||
_renderWeather();
|
||||
} catch {
|
||||
const body = _container.querySelector('#wttr-body');
|
||||
if (body) body.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||||
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">⚠️</div>
|
||||
<h3 style="margin-bottom:var(--space-2)">Wetter nicht verfügbar</h3>
|
||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5)">
|
||||
Die Wetterdaten konnten nicht geladen werden.
|
||||
</p>
|
||||
<button class="btn btn-primary" id="wttr-btn-reload">
|
||||
${UI.icon('arrow-clockwise')} Erneut laden
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
body?.querySelector('#wttr-btn-reload')?.addEventListener('click', () => {
|
||||
refresh();
|
||||
});
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HAUPT-RENDER
|
||||
// ----------------------------------------------------------
|
||||
function _renderWeather() {
|
||||
const body = _container.querySelector('#wttr-body');
|
||||
if (!body || !_data) return;
|
||||
|
||||
const days = _data.days || [];
|
||||
if (!days.length) return;
|
||||
|
||||
body.innerHTML = `
|
||||
<!-- 7-Tage-Strip -->
|
||||
<div id="wttr-strip-wrap"
|
||||
style="overflow-x:auto;-webkit-overflow-scrolling:touch;
|
||||
margin-bottom:var(--space-4);
|
||||
scrollbar-width:none">
|
||||
<div id="wttr-strip"
|
||||
style="display:flex;gap:var(--space-2);padding-bottom:4px;min-width:max-content">
|
||||
${days.map((d, i) => _dayCard(d, i)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail-Card -->
|
||||
<div id="wttr-detail" class="section-card"
|
||||
style="margin-bottom:var(--space-4)">
|
||||
</div>
|
||||
|
||||
<!-- Hunde-Wetter -->
|
||||
<div id="wttr-dog" class="section-card">
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Strip-Klick-Events
|
||||
body.querySelectorAll('[data-wttr-day]').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
_selDay = parseInt(card.dataset.wttrDay);
|
||||
_updateStrip();
|
||||
_renderDetail();
|
||||
_renderDog();
|
||||
});
|
||||
});
|
||||
|
||||
_renderDetail();
|
||||
_renderDog();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// STRIP AKTUALISIEREN (aktiver Tag)
|
||||
// ----------------------------------------------------------
|
||||
function _updateStrip() {
|
||||
const body = _container.querySelector('#wttr-body');
|
||||
if (!body) return;
|
||||
const days = _data?.days || [];
|
||||
body.querySelectorAll('[data-wttr-day]').forEach((card, i) => {
|
||||
const active = i === _selDay;
|
||||
card.style.background = active ? 'var(--c-primary)' : 'var(--c-bg-card)';
|
||||
card.style.color = active ? '#fff' : 'var(--c-text)';
|
||||
card.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
|
||||
card.style.transform = active ? 'translateY(-2px)' : '';
|
||||
card.style.boxShadow = active ? '0 4px 12px rgba(196,132,58,0.3)' : '0 1px 3px rgba(0,0,0,0.07)';
|
||||
// Temperatur-Farbe im aktiven Zustand
|
||||
const tempEl = card.querySelector('.wttr-temp');
|
||||
if (tempEl) tempEl.style.color = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)';
|
||||
const precipEl = card.querySelector('.wttr-precip');
|
||||
if (precipEl) precipEl.style.color = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)';
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAG-KARTE (Strip)
|
||||
// ----------------------------------------------------------
|
||||
function _dayCard(d, i) {
|
||||
const active = i === _selDay;
|
||||
const dateObj = new Date(d.date);
|
||||
const dayName = i === 0 ? 'Heute' : DAY_NAMES[dateObj.getDay()];
|
||||
const bg = active ? 'var(--c-primary)' : 'var(--c-bg-card)';
|
||||
const col = active ? '#fff' : 'var(--c-text)';
|
||||
const shadow = active
|
||||
? '0 4px 12px rgba(196,132,58,0.3)'
|
||||
: '0 1px 3px rgba(0,0,0,0.07)';
|
||||
const border = active ? 'var(--c-primary)' : 'var(--c-border)';
|
||||
const transform = active ? 'translateY(-2px)' : '';
|
||||
const textSec = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)';
|
||||
const textMut = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)';
|
||||
|
||||
return `
|
||||
<div data-wttr-day="${i}"
|
||||
style="display:flex;flex-direction:column;align-items:center;
|
||||
min-width:72px;padding:var(--space-3) var(--space-2);
|
||||
border-radius:var(--radius);border:1.5px solid ${border};
|
||||
background:${bg};color:${col};cursor:pointer;
|
||||
box-shadow:${shadow};transform:${transform};
|
||||
transition:all .15s;user-select:none">
|
||||
<span style="font-size:var(--text-xs);font-weight:600;
|
||||
margin-bottom:var(--space-1)">${_esc(dayName)}</span>
|
||||
<div style="margin-bottom:var(--space-1)">${_wmoIcon(d.weathercode, '1.5rem', active ? 'filter:brightness(0) invert(1)' : '')}</div>
|
||||
<span class="wttr-temp"
|
||||
style="font-size:var(--text-xs);color:${textSec};white-space:nowrap">
|
||||
${Math.round(d.temp_max)}°/<span style="opacity:.75">${Math.round(d.temp_min)}°</span>
|
||||
</span>
|
||||
<span class="wttr-precip"
|
||||
style="font-size:10px;color:${textMut};margin-top:2px">
|
||||
<svg class="ph-icon" style="width:10px;height:10px;vertical-align:-1px;color:#60A5FA"><use href="/icons/phosphor.svg#drop"></use></svg>${d.precip_prob ?? 0}%
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// DETAIL-CARD
|
||||
// ----------------------------------------------------------
|
||||
function _renderDetail() {
|
||||
const el = _container.querySelector('#wttr-detail');
|
||||
if (!el || !_data) return;
|
||||
const d = (_data.days || [])[_selDay];
|
||||
if (!d) return;
|
||||
|
||||
const desc = WMO_DESC[d.weathercode] || '';
|
||||
const [uvLabel, uvColor] = _uvLabel(d.uv_index ?? 0);
|
||||
const uvPct = Math.min(100, ((d.uv_index ?? 0) / 11) * 100);
|
||||
const bft = _beaufort(d.wind_kmh ?? 0);
|
||||
const windDir = d.wind_dir_deg ?? 0;
|
||||
const compass = d.wind_dir ?? _compass(windDir);
|
||||
|
||||
// Sunrise/Sunset Balken
|
||||
const now = new Date();
|
||||
const sunriseStr = d.sunrise || '';
|
||||
const sunsetStr = d.sunset || '';
|
||||
let sunPct = 0;
|
||||
if (sunriseStr && sunsetStr) {
|
||||
const [rH, rM] = sunriseStr.split(':').map(Number);
|
||||
const [sH, sM] = sunsetStr.split(':').map(Number);
|
||||
const riseMin = rH * 60 + rM;
|
||||
const setMin = sH * 60 + sM;
|
||||
const curMin = now.getHours() * 60 + now.getMinutes();
|
||||
sunPct = _selDay === 0
|
||||
? Math.min(100, Math.max(0, ((curMin - riseMin) / (setMin - riseMin)) * 100))
|
||||
: 0;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
|
||||
${_wmoIcon(d.weathercode, '3.5rem')}
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(desc)}</div>
|
||||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary);line-height:1.1">
|
||||
${Math.round(d.temp_max)}°
|
||||
<span style="font-size:var(--text-base);font-weight:400;color:var(--c-text-secondary)">
|
||||
/ ${Math.round(d.temp_min)}°
|
||||
</span>
|
||||
</div>
|
||||
${d.feels_max != null ? `
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||||
Gefühlt ${Math.round(d.feels_max)}° / ${Math.round(d.feels_min ?? d.feels_max)}°
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sonnenaufgang / -untergang -->
|
||||
${sunriseStr && sunsetStr ? `
|
||||
<div style="margin-bottom:var(--space-4)">
|
||||
<div style="display:flex;justify-content:space-between;
|
||||
font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
margin-bottom:var(--space-1)">
|
||||
<span style="display:flex;align-items:center;gap:4px">
|
||||
<svg class="ph-icon" style="width:14px;height:14px;color:#F97316"><use href="/icons/phosphor.svg#sun-horizon"></use></svg>
|
||||
${_esc(sunriseStr)}
|
||||
</span>
|
||||
<span style="display:flex;align-items:center;gap:4px">
|
||||
${_esc(sunsetStr)}
|
||||
<svg class="ph-icon" style="width:14px;height:14px;color:#7C3AED"><use href="/icons/phosphor.svg#moon-stars"></use></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div style="height:6px;border-radius:999px;background:var(--c-border);overflow:hidden">
|
||||
<div style="height:100%;width:${sunPct}%;
|
||||
background:linear-gradient(90deg,#f97316,#facc15);
|
||||
border-radius:999px;transition:width .4s"></div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Wind -->
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);
|
||||
padding:var(--space-3);border-radius:var(--radius);
|
||||
background:var(--c-bg-card);border:1px solid var(--c-border);
|
||||
margin-bottom:var(--space-3)">
|
||||
<span style="font-size:1.4rem;transform:rotate(${windDir}deg);display:inline-block;line-height:1">
|
||||
${UI.icon('arrow-up')}
|
||||
</span>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:var(--text-sm);font-weight:600">
|
||||
${_esc(compass)} · ${Math.round(d.windspeed_max ?? 0)} km/h
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(bft)}</div>
|
||||
</div>
|
||||
${d.precip_sum != null ? `
|
||||
<div style="text-align:right">
|
||||
<div style="font-size:var(--text-sm);font-weight:600">
|
||||
${d.precip_sum} mm
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Niederschlag</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- UV-Index -->
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;
|
||||
font-size:var(--text-xs);margin-bottom:4px">
|
||||
<span style="color:var(--c-text-secondary)">UV-Index</span>
|
||||
<span style="font-weight:600;color:${uvColor}">
|
||||
${d.uv_index ?? 0} — ${_esc(uvLabel)}
|
||||
</span>
|
||||
</div>
|
||||
<div style="height:6px;border-radius:999px;background:var(--c-border);overflow:hidden">
|
||||
<div style="height:100%;width:${uvPct}%;background:${uvColor};
|
||||
border-radius:999px;transition:width .4s"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HUNDE-WETTER
|
||||
// ----------------------------------------------------------
|
||||
function _renderDog() {
|
||||
const el = _container.querySelector('#wttr-dog');
|
||||
if (!el || !_data) return;
|
||||
const d = (_data.days || [])[_selDay];
|
||||
if (!d) return;
|
||||
|
||||
const _POLLEN_NAMES = { erle:'Erle', birke:'Birke', graeser:'Gräser', beifuss:'Beifuß', ambrosia:'Ambrosia' };
|
||||
let html = `<h3 style="font-size:var(--text-base);font-weight:700;
|
||||
margin-bottom:var(--space-4)">
|
||||
<svg class="ph-icon" style="width:1.1em;height:1.1em;vertical-align:-2px;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
|
||||
Hunde-Wetter
|
||||
</h3>`;
|
||||
|
||||
// Asphalt-Temperatur
|
||||
if (d.asphalt_temp != null) {
|
||||
const [aspText, aspColor, aspAdvice] = _asphaltLevel(d.asphalt_temp);
|
||||
html += `
|
||||
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
|
||||
padding:var(--space-3);border-radius:var(--radius);
|
||||
background:${aspColor}1a;border:1px solid ${aspColor}55;
|
||||
margin-bottom:var(--space-3)">
|
||||
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:600;font-size:var(--text-sm);color:${aspColor}">
|
||||
Asphalt ~${Math.round(d.asphalt_temp)}°C — ${_esc(aspText)}
|
||||
</div>
|
||||
${aspAdvice ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||||
${_esc(aspAdvice)}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Pfoten-Kälteschutz
|
||||
if (d.paw_cold) {
|
||||
html += `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);
|
||||
padding:var(--space-3);border-radius:var(--radius);
|
||||
background:#3b82f61a;border:1px solid #3b82f655;
|
||||
margin-bottom:var(--space-3)">
|
||||
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#38BDF8"><use href="/icons/phosphor.svg#snowflake"></use></svg>
|
||||
<div style="font-size:var(--text-sm)">
|
||||
<strong>Kälteschutz für Pfoten:</strong>
|
||||
Eis und Streusalz können die Pfoten reizen. Pfotenpflege empfohlen.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Gewitter
|
||||
if (d.thunderstorm) {
|
||||
html += `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);
|
||||
padding:var(--space-3);border-radius:var(--radius);
|
||||
background:#f59e0b1a;border:1px solid #f59e0b55;
|
||||
margin-bottom:var(--space-3)">
|
||||
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#7C3AED"><use href="/icons/phosphor.svg#cloud-lightning"></use></svg>
|
||||
<div style="font-size:var(--text-sm)">
|
||||
<strong>Gewitter erwartet:</strong>
|
||||
Hunde können auf Gewitter sensibel reagieren. Sichere Umgebung schaffen.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Pollenflug
|
||||
const pollen = d.pollen;
|
||||
if (pollen && typeof pollen === 'object' && Object.keys(pollen).length) {
|
||||
const pollenEntries = Object.entries(pollen)
|
||||
.filter(([, v]) => v != null && v.level > 0);
|
||||
if (pollenEntries.length) {
|
||||
html += `
|
||||
<div style="margin-bottom:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);font-weight:600;
|
||||
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
||||
<svg class="ph-icon" style="width:1em;height:1em;vertical-align:-1px;color:#16A34A"><use href="/icons/phosphor.svg#leaf"></use></svg>
|
||||
Pollenflug
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
|
||||
${pollenEntries.map(([key, lvlObj]) => {
|
||||
const col = _pollenColor(lvlObj?.level ?? 0);
|
||||
const name = _POLLEN_NAMES[key] || key;
|
||||
const lbl = lvlObj?.label || '';
|
||||
return `<span style="display:inline-flex;align-items:center;gap:4px;
|
||||
font-size:var(--text-xs);border-radius:999px;
|
||||
padding:3px 10px;background:${col}22;
|
||||
border:1px solid ${col}55;color:${col};font-weight:600">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:${col};display:inline-block"></span>
|
||||
${_esc(name)}: ${_esc(lbl)}
|
||||
</span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Zecken
|
||||
if (d.zecken != null) {
|
||||
const [tickLabel, tickColor] = _tickLevel(d.zecken);
|
||||
html += `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);
|
||||
padding:var(--space-3);border-radius:var(--radius);
|
||||
background:${tickColor}1a;border:1px solid ${tickColor}55;
|
||||
margin-bottom:var(--space-3)">
|
||||
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#92400E"><use href="/icons/phosphor.svg#bug"></use></svg>
|
||||
<div style="flex:1">
|
||||
<span style="font-size:var(--text-sm);font-weight:600">Zecken-Risiko: </span>
|
||||
<span style="font-size:var(--text-sm);color:${tickColor};font-weight:700">
|
||||
${_esc(tickLabel)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Wenn keine Hunde-Daten vorhanden
|
||||
if (!d.asphalt_temp && !d.paw_cold && !d.thunderstorm
|
||||
&& !d.zecken && !(pollen && Object.keys(pollen).length)) {
|
||||
html += `
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
Keine besonderen Hinweise für heute.
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HILFSFUNKTIONEN — Wetter
|
||||
// ----------------------------------------------------------
|
||||
function _beaufort(kmh) {
|
||||
if (kmh < 2) return 'Windstille';
|
||||
if (kmh < 12) return 'leicht';
|
||||
if (kmh < 29) return 'mäßig';
|
||||
if (kmh < 50) return 'frisch';
|
||||
if (kmh < 62) return 'stark';
|
||||
if (kmh < 75) return 'stürmisch';
|
||||
return 'Sturm';
|
||||
}
|
||||
|
||||
function _uvLabel(uv) {
|
||||
if (uv <= 2) return ['niedrig', '#4CAF50'];
|
||||
if (uv <= 5) return ['mittel', '#FFC107'];
|
||||
if (uv <= 7) return ['hoch', '#FF9800'];
|
||||
if (uv <= 10) return ['sehr hoch', '#F44336'];
|
||||
return ['extrem', '#9C27B0'];
|
||||
}
|
||||
|
||||
function _compass(deg) {
|
||||
const dirs = ['N','NO','O','SO','S','SW','W','NW'];
|
||||
return dirs[Math.round(deg / 45) % 8];
|
||||
}
|
||||
|
||||
function _asphaltLevel(temp) {
|
||||
if (temp < 40) return ['Pfoten sicher', '#4CAF50', ''];
|
||||
if (temp < 50) return ['leicht erwärmt', '#FFC107',
|
||||
'Kurze Kontaktzeiten sind unbedenklich.'];
|
||||
if (temp < 60) return ['Vorsicht — Pfoten schützen!', '#FF9800',
|
||||
'Heiße Oberfläche! Auf Gras ausweichen oder Hundeschuhe verwenden.'];
|
||||
return ['GEFAHR — Verbrennungsgefahr!', '#F44336',
|
||||
'Asphalt kann Pfoten in Sekunden verbrennen. Spaziergang vermeiden!'];
|
||||
}
|
||||
|
||||
function _pollenColor(level) {
|
||||
if (level === 0) return '#9E9E9E';
|
||||
if (level === 1) return '#4CAF50';
|
||||
if (level === 2) return '#FFC107';
|
||||
if (level === 3) return '#FF9800';
|
||||
return '#F44336'; // level 4+
|
||||
}
|
||||
|
||||
function _tickLevel(risk) {
|
||||
const r = (risk || '').toLowerCase();
|
||||
if (r === 'niedrig') return ['niedrig', '#4CAF50'];
|
||||
if (r === 'mittel') return ['mittel', '#FF9800'];
|
||||
return ['hoch', '#F44336'];
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC API
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
|
||||
})();
|
||||
|
|
@ -255,6 +255,15 @@ window.Page_wiki = (() => {
|
|||
<option value="">Alle Gruppen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="padding:0 0 var(--space-3)">
|
||||
<button class="btn btn-secondary w-full" id="wiki-rasse-erkennen-btn"
|
||||
style="font-size:var(--text-sm)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||||
Welche Rasse ist das? — Foto analysieren
|
||||
</button>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp"
|
||||
id="wiki-rasse-foto-input" style="display:none">
|
||||
</div>
|
||||
<div class="wiki-breed-grid" id="wiki-breed-grid"></div>
|
||||
<div id="wiki-mehr-wrap" style="text-align:center;padding:var(--space-4) 0;display:none">
|
||||
<button class="btn btn-secondary" id="wiki-mehr-btn">Mehr laden</button>
|
||||
|
|
@ -264,6 +273,9 @@ window.Page_wiki = (() => {
|
|||
// Load initial batch (also populates gruppen)
|
||||
await _loadBreeds(el, true);
|
||||
|
||||
// Rassen-Erkennung per KI
|
||||
_bindWikiRasseErkennung(el);
|
||||
|
||||
// Search handler with debounce
|
||||
let _searchTimer;
|
||||
el.querySelector('#wiki-rassen-search').addEventListener('input', e => {
|
||||
|
|
@ -1265,6 +1277,130 @@ window.Page_wiki = (() => {
|
|||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RASSEN-ERKENNUNG PER KI (Wiki-Tab)
|
||||
// ----------------------------------------------------------
|
||||
function _bindWikiRasseErkennung(el) {
|
||||
const btn = el.querySelector('#wiki-rasse-erkennen-btn');
|
||||
const fileInput = el.querySelector('#wiki-rasse-foto-input');
|
||||
if (!btn || !fileInput) return;
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
if (!_appState.user) {
|
||||
UI.toast('Bitte melde dich an, um diese Funktion zu nutzen.', 'info');
|
||||
return;
|
||||
}
|
||||
fileInput.value = '';
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', async () => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
UI.toast('Bild zu groß (max. 5 MB).', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> KI analysiert das Bild…`;
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const token = localStorage.getItem('by_token');
|
||||
const resp = await fetch('/api/ki/rasse-erkennung', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.');
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
_showWikiRasseErgebnis(data);
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
UI.toast(e.message || 'Fehler bei der Rassen-Erkennung.', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _showWikiRasseErgebnis(data) {
|
||||
if (!data.ist_hund) {
|
||||
UI.modal.open({
|
||||
title: 'Kein Hund erkannt',
|
||||
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
|
||||
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div>
|
||||
<p style="color:var(--c-text-secondary)">
|
||||
Auf diesem Foto konnte kein Hund erkannt werden.<br>
|
||||
Bitte lade ein deutlicheres Foto hoch.
|
||||
</p>
|
||||
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${_esc(data.hinweis)}</p>` : ''}
|
||||
</div>`,
|
||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rassen = data.rassen || [];
|
||||
const cardsHtml = rassen.map((r, i) => {
|
||||
const isTop = i === 0;
|
||||
return `
|
||||
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${_esc(r.name)}</div>
|
||||
<span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
|
||||
</div>
|
||||
<div class="rasse-result-bar-wrap">
|
||||
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
|
||||
style="width:${r.sicherheit}%"></div>
|
||||
</div>
|
||||
${r.beschreibung ? `<div class="rasse-result-desc">${_esc(r.beschreibung)}</div>` : ''}
|
||||
${r.wiki_slug ? `
|
||||
<div style="margin-top:var(--space-3)">
|
||||
<button class="btn btn-${isTop ? 'primary' : 'secondary'} btn-sm w-full"
|
||||
data-action="wiki" data-slug="${_esc(r.wiki_slug)}">
|
||||
Im Wiki nachschlagen
|
||||
</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
UI.modal.open({
|
||||
title: 'Erkannte Rasse',
|
||||
body: `
|
||||
<div style="padding-bottom:var(--space-2)">
|
||||
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||
padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm);
|
||||
color:var(--c-text-secondary)">ℹ️ ${_esc(data.hinweis)}</div>` : ''}
|
||||
${cardsHtml}
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
|
||||
text-align:center">
|
||||
Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
footer: `<button class="btn btn-secondary" id="wiki-rasse-modal-schliessen">Schließen</button>`,
|
||||
});
|
||||
|
||||
document.getElementById('wiki-rasse-modal-schliessen')
|
||||
?.addEventListener('click', UI.modal.close);
|
||||
|
||||
document.querySelectorAll('[data-action="wiki"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
UI.modal.close();
|
||||
setTimeout(() => _openBreedDetail(btn.dataset.slug), 300);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
1071
backend/static/js/worlds.js
Normal file
1071
backend/static/js/worlds.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue