Sprint 19: Social, UX-Verbesserungen, Nerd2Noob-Hilfe

This commit is contained in:
rene 2026-04-17 23:53:50 +02:00
parent 10d30bf565
commit 89d87030a2
18 changed files with 930 additions and 74 deletions

View file

@ -5044,3 +5044,93 @@ textarea.form-control {
color: var(--c-text-muted);
margin-top: 2px;
}
/* health chart extras (weight chart) */
.health-chart-title { font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--c-text); margin-bottom: var(--space-2); }
.health-chart-svg { width: 100%; height: auto; display: block; }
.health-chart-labels { display: flex; justify-content: space-between; font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: var(--space-1); }
.health-chart-empty { font-size: var(--text-sm); color: var(--c-text-muted); text-align: center; padding: var(--space-4) 0; }
/* ============================================================
RSVP Event-Teilnahme
============================================================ */
.event-rsvp-bar { display:flex; gap:var(--space-2); align-items:center; margin:var(--space-3) 0 var(--space-2); flex-wrap:wrap; }
.event-rsvp-btn { display:inline-flex; align-items:center; gap:var(--space-1); padding:6px 14px; border-radius:var(--radius); border:1.5px solid var(--c-border); background:var(--c-surface); color:var(--c-text-secondary); font-size:var(--text-sm); font-weight:500; cursor:pointer; transition:background .15s,color .15s,border-color .15s; }
.event-rsvp-btn:hover { border-color:var(--c-primary); color:var(--c-primary); }
.event-rsvp-btn.active { background:var(--c-primary); border-color:var(--c-primary); color:#fff; }
.event-attendees { font-size:var(--text-sm); color:var(--c-text-secondary); cursor:pointer; display:inline-flex; align-items:center; gap:var(--space-1); }
.event-attendees:hover { color:var(--c-primary); }
.ev-attendees-list { display:flex; flex-wrap:wrap; gap:var(--space-1); margin-top:var(--space-2); }
.ev-attendee-chip { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:999px; background:var(--c-surface-2); font-size:var(--text-xs); color:var(--c-text-secondary); }
/* ============================================================
SERVICES / MATCHING (Sitting & Walks Anbieter-Suche)
============================================================ */
.svc-matching-layout { display:flex; flex-direction:column; gap:var(--space-4); padding:var(--space-3) 0; }
.svc-own-offer { padding:var(--space-4); }
.svc-own-offer-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-3); }
.svc-own-offer-title { font-weight:var(--weight-semibold); font-size:var(--text-base); }
.svc-login-hint { font-size:var(--text-sm); color:var(--c-text-muted); }
.svc-toggle { position:relative; display:inline-block; width:44px; height:24px; cursor:pointer; }
.svc-toggle input { opacity:0; width:0; height:0; }
.svc-toggle-slider { position:absolute; inset:0; background:var(--c-border); border-radius:var(--radius-full); transition:background var(--transition-fast); }
.svc-toggle-slider::before { content:''; position:absolute; width:18px; height:18px; left:3px; top:3px; background:#fff; border-radius:50%; transition:transform var(--transition-fast); box-shadow:0 1px 3px rgba(0,0,0,.2); }
.svc-toggle input:checked + .svc-toggle-slider { background:var(--c-primary); }
.svc-toggle input:checked + .svc-toggle-slider::before { transform:translateX(20px); }
.svc-offer-form { display:flex; flex-direction:column; gap:var(--space-3); }
.svc-offer-form--hidden { display:none; }
.svc-hint { color:var(--c-text-secondary); font-size:var(--text-sm); text-align:center; padding:var(--space-6) 0; }
.svc-results-list { display:flex; flex-direction:column; gap:var(--space-3); }
.svc-card { display:flex; align-items:flex-start; gap:var(--space-3); padding:var(--space-4); background:var(--c-surface); border-radius:var(--radius-lg); border:1px solid var(--c-border-light); box-shadow:var(--shadow-xs); }
.svc-card-avatar { width:44px; height:44px; border-radius:var(--radius-full); background:var(--c-primary-subtle); color:var(--c-primary); display:flex; align-items:center; justify-content:center; font-size:1.4rem; flex-shrink:0; }
.svc-card-body { flex:1; min-width:0; }
.svc-card-name { font-weight:var(--weight-semibold); margin-bottom:var(--space-1); }
.svc-card-dist { font-size:var(--text-xs); color:var(--c-text-muted); margin-bottom:var(--space-1); }
.svc-card-desc { font-size:var(--text-sm); color:var(--c-text-secondary); overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
.svc-card-side { display:flex; flex-direction:column; align-items:flex-end; gap:var(--space-2); flex-shrink:0; }
.svc-card-price { font-weight:var(--weight-bold); color:var(--c-primary); font-size:var(--text-sm); }
/* ============================================================
HELP TOOLTIP
============================================================ */
.by-help-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px; height: 18px;
border-radius: 50%;
background: var(--c-surface-2);
color: var(--c-text-secondary);
border: none;
cursor: pointer;
vertical-align: middle;
margin-left: 4px;
flex-shrink: 0;
transition: background .15s;
}
.by-help-btn:hover { background: var(--c-primary-subtle, #e8f0fe); color: var(--c-primary); }
.by-help-tooltip {
position: absolute;
z-index: 9000;
background: var(--c-text);
color: var(--c-bg);
font-size: var(--text-xs);
line-height: 1.5;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
max-width: 240px;
box-shadow: 0 4px 12px rgba(0,0,0,.15);
pointer-events: none;
}
/* SVG-Icon-Variante (Phosphor) */
.empty-state-icon > svg.ph-icon,
svg.empty-state-icon {
width: 56px;
height: 56px;
color: var(--c-text-muted);
opacity: .5;
}
.empty-state-cta {
margin-top: var(--space-2);
}

View file

@ -139,6 +139,9 @@ const API = (() => {
symptomCheck(dogId, symptoms) {
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
},
gewichtVerlauf(dogId) {
return get(`/dogs/${dogId}/health/gewicht`);
},
};
// ----------------------------------------------------------
@ -230,10 +233,13 @@ const API = (() => {
const q = new URLSearchParams(params).toString();
return get(`/events${q ? '?' + q : ''}`);
},
get(id) { return get(`/events/${id}`); },
create(data) { return post('/events', data); },
update(id, data) { return patch(`/events/${id}`, data); },
delete(id) { return del(`/events/${id}`); },
get(id) { return get(`/events/${id}`); },
create(data) { return post('/events', data); },
update(id, data) { return patch(`/events/${id}`, data); },
delete(id) { return del(`/events/${id}`); },
rsvp(id, status) { return post(`/events/${id}/rsvp`, { status }); },
cancelRsvp(id) { return del(`/events/${id}/rsvp`); },
listRsvp(id) { return get(`/events/${id}/rsvp`); },
};
// ----------------------------------------------------------
@ -445,6 +451,20 @@ const API = (() => {
delete(id) { return del(`/notifications/${id}`); },
};
// ----------------------------------------------------------
// SERVICE-ANGEBOTE (Sitting & Walks Matching)
// ----------------------------------------------------------
const services = {
list(type, lat = null, lon = null, radius = 20) {
const p = new URLSearchParams({ type, radius });
if (lat !== null) { p.set('lat', lat); p.set('lon', lon); }
return get(`/services?${p}`);
},
me() { return get('/services/me'); },
upsert(data) { return post('/services', data); },
deactivate(id) { return del(`/services/${id}`); },
};
const importData = {
notestation(dogId, file) {
const fd = new FormData();
@ -477,7 +497,7 @@ const API = (() => {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications,
friends, chat, webcal, importData, sharing, widget, notifications, services,
subscribeToPush, getLocation,
APIError,
};

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '128'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '129'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {

View file

@ -206,6 +206,20 @@ window.Page_diary = (() => {
UI.setLoading(btn, false);
}
// ----------------------------------------------------------
// EMPTY-STATE HELPER
// ----------------------------------------------------------
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ----------------------------------------------------------
// LISTE RENDERN — Timeline gruppiert nach Monat
// ----------------------------------------------------------
@ -214,12 +228,12 @@ window.Page_diary = (() => {
if (!listEl) return;
if (_entries.length === 0) {
listEl.innerHTML = UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>',
title: 'Noch keine Einträge',
text: 'Halte besondere Momente mit deinem Hund fest.',
action: `<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag erstellen</button>`,
});
listEl.innerHTML = _emptyState(
'book-open',
'Noch keine Tagebucheinträge',
'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Erlebnisse, Erinnerungen.',
`<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag schreiben</button>`
);
listEl.querySelector('#diary-first-entry')
?.addEventListener('click', () => _showForm(null));
return;

View file

@ -582,6 +582,7 @@ window.Page_dog_profile = (() => {
<label class="form-label">
Rasse
<span style="color:var(--c-text-secondary)">(optional)</span>
${UI.help('Die Rasse wird für Rasseninformationen und Statistiken verwendet.')}
</label>
<input class="form-control" type="text" name="rasse"
value="${_esc(dog?.rasse || '')}"
@ -612,7 +613,10 @@ window.Page_dog_profile = (() => {
min="0.1" max="120" step="0.1" placeholder="z. B. 28.5">
</div>
<div class="form-group">
<label class="form-label">Chip-Nummer</label>
<label class="form-label">
Chip-Nummer
${UI.help('Die 15-stellige Chip-Nummer findest du im Heimtierausweis oder beim Tierarzt.')}
</label>
<input class="form-control" type="text" name="chip_nr"
value="${_esc(dog?.chip_nr || '')}" placeholder="15-stellig">
</div>

View file

@ -39,6 +39,7 @@ window.Page_events = (() => {
let _map = null;
let _markers = [];
let _clusterGroup = null;
let _myRsvp = {}; // { [event_id]: 'going'|'maybe'|null }
// ----------------------------------------------------------
// Phosphor-Icon-Helper
@ -47,6 +48,17 @@ window.Page_events = (() => {
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
}
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ----------------------------------------------------------
// init
// ----------------------------------------------------------
@ -133,7 +145,11 @@ window.Page_events = (() => {
const filtered = _filtered();
if (!filtered.length) {
listEl.innerHTML = UI.emptyState({ icon: '🎪', title: 'Keine Events', text: 'Noch keine Veranstaltungen geplant.' });
listEl.innerHTML = _emptyState(
'calendar-blank',
'Keine Events in der Nähe',
'Hier erscheinen Hundeveranstaltungen, Treffen und Aktivitäten in deiner Umgebung.'
);
return;
}
@ -185,6 +201,7 @@ window.Page_events = (() => {
${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''}
${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}` : ''}
</div>
${ev.rsvp_count ? `<span class="event-attendees" data-ev-attendees="${ev.id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : ''}
${ev.link ? `<div class="events-card-actions">
<a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
${_icon('arrow-square-out')} Details
@ -326,12 +343,26 @@ window.Page_events = (() => {
let ev;
try { ev = await API.events.get(id); } catch { return; }
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const color = TYP_COLOR[ev.typ] || '#6b7280';
const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const isOwn = _state.user?.id === ev.user_id;
const isVdh = ev.quelle === 'vdh';
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const color = TYP_COLOR[ev.typ] || '#6b7280';
const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const isOwn = _state.user?.id === ev.user_id;
const isVdh = ev.quelle === 'vdh';
const myRsvp = _myRsvp[id] ?? null;
// RSVP-Bar (nur für eingeloggte User)
const rsvpBar = _state.user ? `
<div class="event-rsvp-bar" id="ev-rsvp-bar-${id}">
<button class="btn event-rsvp-btn ${myRsvp === 'going' ? 'active' : ''}" data-rsvp-id="${id}" data-rsvp-status="going">
${_icon('check-circle')} Ich komme
</button>
<button class="btn event-rsvp-btn ${myRsvp === 'maybe' ? 'active' : ''}" data-rsvp-id="${id}" data-rsvp-status="maybe">
${_icon('question')} Vielleicht
</button>
${ev.rsvp_count ? `<span class="event-attendees" id="ev-attendees-${id}" data-ev-attendees="${id}" style="margin-left:auto">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : `<span class="event-attendees" id="ev-attendees-${id}" data-ev-attendees="${id}" style="margin-left:auto;display:none">${_icon('users')} 0 nehmen teil</span>`}
</div>
` : (ev.rsvp_count ? `<div class="event-rsvp-bar"><span class="event-attendees" data-ev-attendees="${id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span></div>` : '');
const body = `
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
@ -348,6 +379,8 @@ window.Page_events = (() => {
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">
${_icon('user')} Veranstalter: ${UI.escHtml(ev.veranstalter_name || '')}
</div>
${rsvpBar}
<div id="ev-attendees-panel-${id}"></div>
`;
const footer = isOwn ? `
@ -365,6 +398,80 @@ window.Page_events = (() => {
UI.modal.close(); setTimeout(() => _openForm(ev), 50);
});
document.getElementById('ev-detail-del')?.addEventListener('click', () => _deleteEvent(ev));
// RSVP-Buttons
document.querySelectorAll(`[data-rsvp-id="${id}"]`).forEach(btn => {
btn.addEventListener('click', () => _handleRsvp(id, btn.dataset.rsvpStatus));
});
}
async function _handleRsvp(eventId, status) {
const current = _myRsvp[eventId] ?? null;
try {
if (current === status) {
// Toggle off → absagen
await API.events.cancelRsvp(eventId);
_myRsvp[eventId] = null;
} else {
const res = await API.events.rsvp(eventId, status);
_myRsvp[eventId] = status;
// Teilnehmerzähler aktualisieren
_updateAttendeeCount(eventId, res.rsvp_count);
}
// Button-Styles aktualisieren
document.querySelectorAll(`[data-rsvp-id="${eventId}"]`).forEach(btn => {
btn.classList.toggle('active', btn.dataset.rsvpStatus === (_myRsvp[eventId] ?? ''));
});
// Bei Absage Zähler neu laden
if (current === status) {
const attendees = await API.events.listRsvp(eventId);
const goingCount = attendees.filter(a => a.status === 'going').length;
_updateAttendeeCount(eventId, goingCount);
}
} catch (e) { UI.toast(e.message, 'error'); }
}
function _updateAttendeeCount(eventId, count) {
// Im Modal
const span = document.getElementById(`ev-attendees-${eventId}`);
if (span) {
if (count > 0) {
span.innerHTML = `${_icon('users')} ${count} nehmen teil`;
span.style.display = '';
} else {
span.style.display = 'none';
}
}
// In der Listenansicht (Event-Objekt aktualisieren)
const ev = _events.find(x => x.id === eventId);
if (ev) {
ev.rsvp_count = count;
// Karte neu rendern falls sichtbar
const card = document.querySelector(`[data-ev-id="${eventId}"]`);
if (card) card.outerHTML = _cardHTML(ev);
}
}
async function _showAttendees(eventId) {
const panel = document.getElementById(`ev-attendees-panel-${eventId}`);
if (!panel) return;
if (panel.dataset.loaded) { panel.innerHTML = ''; delete panel.dataset.loaded; return; }
try {
const attendees = await API.events.listRsvp(eventId);
if (!attendees.length) { panel.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-2)">Noch keine Zusagen.</p>'; }
else {
panel.innerHTML = `
<div class="ev-attendees-list">
${attendees.map(a => `
<span class="ev-attendee-chip">
${a.status === 'going' ? _icon('check-circle') : _icon('question')}
${UI.escHtml(a.name)}
</span>
`).join('')}
</div>`;
}
panel.dataset.loaded = '1';
} catch { /* ignore */ }
}
async function _deleteEvent(ev) {
@ -548,6 +655,14 @@ window.Page_events = (() => {
return;
}
// Teilnehmer-Liste anzeigen (Karten-Ansicht oder Modal)
const attendeesBtn = e.target.closest('[data-ev-attendees]');
if (attendeesBtn) {
e.stopPropagation();
_showAttendees(parseInt(attendeesBtn.dataset.evAttendees));
return;
}
// Karten-Klick → Detail
const card = e.target.closest('[data-ev-id]');
if (card) { _showDetail(parseInt(card.dataset.evId)); }

View file

@ -366,19 +366,15 @@ window.Page_friends = (() => {
const el = _container.querySelector('#fr-list');
if (!list.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-border);
margin-bottom:var(--space-3)" aria-hidden="true">
<use href="/icons/phosphor.svg#paw-print"></use>
</svg>
<p style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-2)">Noch keine Hundefreunde</p>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Suche oben nach anderen Hundebesitzern und schick ihnen eine Anfrage.
</p>
</div>
`;
el.innerHTML = _emptyState(
'users-three',
'Noch keine Freunde',
'Verbinde dich mit anderen Hundebesitzern. Teile Routen, sieh Aktivitäten und schreib Nachrichten.',
`<button class="btn btn-primary" id="fr-empty-search">Freunde suchen</button>`
);
el.querySelector('#fr-empty-search')?.addEventListener('click', () => {
_container.querySelector('#fr-search')?.focus();
});
return;
}
@ -774,6 +770,17 @@ window.Page_friends = (() => {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ----------------------------------------------------------
return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat };

View file

@ -321,6 +321,11 @@ window.Page_health = (() => {
} catch (err) {
// silent fail
}
try {
_data['gewicht_chart'] = await API.health.gewichtVerlauf(dogId);
} catch (err) {
_data['gewicht_chart'] = [];
}
}
// ----------------------------------------------------------
@ -347,15 +352,33 @@ window.Page_health = (() => {
_bindTabEvents(content);
}
// ----------------------------------------------------------
// EMPTY-STATE HELPER
// ----------------------------------------------------------
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ----------------------------------------------------------
// IMPFUNGEN — mit Ampel-Status
// ----------------------------------------------------------
function _renderImpfungen(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
const helpIcon = UI.help('Trage Impfdatum und nächsten Termin ein — wir erinnern dich rechtzeitig.');
if (!entries.length) return UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn
});
if (!entries.length) return _emptyState(
'syringe',
'Noch keine Impfungen',
`Trage alle Impfungen ein, um nichts zu verpassen. ${helpIcon}`,
addBtn
);
const items = entries.map(e => {
const ampel = _impfAmpel(e.naechstes);
@ -453,7 +476,8 @@ window.Page_health = (() => {
</div>`;
})() : '';
const chart = sorted.length >= 2 ? _weightChart(sorted) : '';
const chartEntries = _data['gewicht_chart'] || [];
const chart = _renderWeightChart(chartEntries);
const items = sorted.slice().reverse().map(e => `
<div class="health-card" data-id="${e.id}" data-action="open-entry"
@ -478,7 +502,13 @@ window.Page_health = (() => {
</div>
${deltaHtml}
</div>
${chart ? `<div class="health-chart-wrap">${chart}</div>` : ''}
${chart ? `<div class="health-chart-wrap">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3) 0;display:flex;align-items:center;gap:var(--space-1)">
Gewichtsverlauf ${UI.help('Wird aus allen Einträgen mit Gewichtsangabe berechnet.')}
</div>
${chart}
</div>` : ''}
<div class="health-list" style="margin-top:var(--space-2)">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
`;
@ -556,6 +586,62 @@ window.Page_health = (() => {
`;
}
// ----------------------------------------------------------
// GEWICHTSVERLAUF-CHART (dedizierter Endpoint /health/gewicht)
// ----------------------------------------------------------
function _renderWeightChart(entries) {
// entries: [{datum, gewicht}, ...]
if (!entries || entries.length < 2) {
return '<p class="health-chart-empty">Mindestens 2 Gewichtseinträge für den Verlauf nötig.</p>';
}
const W = 300, H = 120, PAD = 24;
const weights = entries.map(e => e.gewicht);
const min = Math.min(...weights), max = Math.max(...weights);
const range = max - min || 1;
// x: gleichmäßig verteilt, y: normalisiert
const pts = entries.map((e, i) => {
const x = PAD + (i / (entries.length - 1)) * (W - 2 * PAD);
const y = H - PAD - ((e.gewicht - min) / range) * (H - 2 * PAD);
return { x, y, ...e };
});
const polyline = pts.map(p => `${p.x},${p.y}`).join(' ');
const area = `${pts[0].x},${H - PAD} ` + polyline + ` ${pts[pts.length - 1].x},${H - PAD}`;
// Datenpunkte + Tooltips als title-Elemente
const circles = pts.map(p =>
`<circle cx="${p.x}" cy="${p.y}" r="4" fill="var(--c-primary)">
<title>${p.datum}: ${p.gewicht} kg</title>
</circle>`
).join('');
const gId = `wg${Math.random().toString(36).slice(2, 7)}`;
return `
<div class="health-chart-wrap">
<div class="health-chart-title">Gewichtsverlauf</div>
<svg viewBox="0 0 ${W} ${H}" class="health-chart-svg" aria-label="Gewichtsverlauf">
<defs>
<linearGradient id="${gId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity=".25"/>
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0"/>
</linearGradient>
</defs>
<polygon points="${area}" fill="url(#${gId})"/>
<polyline points="${polyline}" fill="none" stroke="var(--c-primary)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
${circles}
<text x="${PAD - 2}" y="${H - PAD + 4}" font-size="9" fill="var(--c-text-secondary)" text-anchor="middle">${min}</text>
<text x="${PAD - 2}" y="${PAD + 4}" font-size="9" fill="var(--c-text-secondary)" text-anchor="middle">${max}</text>
</svg>
<div class="health-chart-labels">
<span>${entries[0].datum}</span>
<span>${entries[entries.length - 1].datum}</span>
</div>
</div>
`;
}
// ----------------------------------------------------------
// LÄUFIGKEIT — Timeline + Vorhersage
// ----------------------------------------------------------
@ -927,7 +1013,10 @@ window.Page_health = (() => {
const uploadField = t === 'dokument' ? `
<div class="form-group">
<label class="form-label">Datei (JPG, PNG, PDF)</label>
<label class="form-label">
Datei (JPG, PNG, PDF)
${UI.help('PDF oder Foto — z.B. Impfpass, Röntgenbild, Befund.')}
</label>
<input class="form-control" type="file" name="datei" accept="image/*,.pdf">
</div>
` : '';

View file

@ -257,12 +257,12 @@ window.Page_lost = (() => {
if (!listEl) return;
if (_reports.length === 0) {
listEl.innerHTML = UI.emptyState({
icon : '🐾',
title : 'Keine vermissten Hunde',
text : 'In deiner Nähe (25 km) werden aktuell keine Hunde vermisst.',
action: `<button class="btn btn-primary" id="lost-empty-report">🔍 Hund melden</button>`,
});
listEl.innerHTML = _emptyState(
'magnifying-glass',
'Aktuell kein vermisster Hund gemeldet',
'Wenn ein Hund vermisst wird, erscheint die Meldung hier. Du kannst auch selbst eine Meldung erstellen.',
`<button class="btn btn-primary" id="lost-empty-report">Vermissten melden</button>`
);
listEl.querySelector('#lost-empty-report')
?.addEventListener('click', _showReportForm);
return;
@ -680,6 +680,17 @@ window.Page_lost = (() => {
.replace(/"/g, '&quot;');
}
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

View file

@ -45,13 +45,31 @@ window.Page_routes = (() => {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden
try { _userPos = await API.getLocation(); } catch {}
_loadData();
await _loadData();
// Deep-Link: /#routes?id=123 → direkt Route-Detail öffnen
const params = new URLSearchParams((location.hash.split('?')[1] || ''));
const deepId = params.get('id');
if (deepId) {
_openDetail(parseInt(deepId, 10));
}
}
async function _loadLeaflet() {
@ -460,20 +478,12 @@ window.Page_routes = (() => {
</div>`;
} else {
// Noch gar keine eigenen Routen
grid.innerHTML = `<div class="rk-empty rk-empty--onboarding">
<div class="rk-empty-icon">🥾</div>
<h3 class="rk-empty-title">Deine erste Gassi-Route</h3>
<p class="rk-empty-text">Zeichne deine Lieblingsstrecken auf mit Streckendaten, Fotos und Hundetauglichkeit.</p>
<div class="rk-empty-features">
<div class="rk-empty-feature">${UI.icon('map-trifold')}<span>GPS-Aufzeichnung</span></div>
<div class="rk-empty-feature">${UI.icon('camera')}<span>Fotos entlang der Strecke</span></div>
<div class="rk-empty-feature"><span>🐾</span><span>Hundetauglichkeit bewerten</span></div>
<div class="rk-empty-feature">${UI.icon('download-simple')}<span>GPX-Download für Navi</span></div>
<div class="rk-empty-feature">${UI.icon('map-pin')}<span>Restaurants & Parkplätze</span></div>
<div class="rk-empty-feature">${UI.icon('lock')}<span>Privat oder öffentlich</span></div>
</div>
<button class="btn btn-primary btn-lg" id="rk-empty-rec">${UI.icon('path')} Erste Route aufzeichnen</button>
</div>`;
grid.innerHTML = _emptyState(
'map-trifold',
'Noch keine Routen',
'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.',
`<button class="btn btn-primary" id="rk-empty-rec">${UI.icon('path')} Route aufzeichnen</button>`
);
document.getElementById('rk-empty-rec')?.addEventListener('click', () => {
App.navigate('map');
setTimeout(() => window.Page_map?.startRecording?.(), 600);
@ -688,6 +698,8 @@ window.Page_routes = (() => {
const footer = `
<button type="button" class="btn btn-secondary" id="rd-gpx">${UI.icon('download-simple')} GPX</button>
<button type="button" class="btn btn-secondary" id="rd-share" title="Route teilen">${UI.icon('share')}</button>
<button type="button" class="btn btn-secondary" id="rd-send-friend" title="An Freund senden">${UI.icon('chat-circle-dots')} An Freund senden</button>
${isOwn ? `<button type="button" class="btn btn-ghost" id="rd-vis" title="${route.is_public?'Privat machen':'Öffentlich machen'}">
${route.is_public ? UI.icon('lock')+' Privat' : UI.icon('globe')+' Öffentlich'}
</button>
@ -700,6 +712,23 @@ window.Page_routes = (() => {
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route));
// Teilen-Button
document.getElementById('rd-share')?.addEventListener('click', () => {
const shareUrl = location.origin + '/#routes?id=' + route.id;
if (navigator.share) {
navigator.share({ title: route.name, url: shareUrl }).catch(() => {});
} else {
navigator.clipboard.writeText(shareUrl).then(() => {
UI.toast.success('Link kopiert!');
}).catch(() => {
UI.toast.error('Link konnte nicht kopiert werden.');
});
}
});
// An Freund senden
document.getElementById('rd-send-friend')?.addEventListener('click', () => _openSendToFriendModal(route));
// Sichtbarkeit toggle
document.getElementById('rd-vis')?.addEventListener('click', async () => {
try {
@ -1108,6 +1137,7 @@ window.Page_routes = (() => {
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" id="ri-public"> Öffentlich
${UI.help('Öffentliche Routen können von allen Nutzern in der Entdecken-Ansicht gefunden werden.')}
</label>
</div>
</form>
@ -1173,6 +1203,71 @@ window.Page_routes = (() => {
return Array.from({ length: maxPts }, (_, i) => track[Math.round(i * step)]);
}
// ----------------------------------------------------------
// An Freund senden
// ----------------------------------------------------------
async function _openSendToFriendModal(route) {
const shareUrl = location.origin + '/#routes?id=' + route.id;
// Freunde laden
let friends = [];
try {
friends = await API.friends.list();
} catch (err) {
UI.toast.error('Freunde konnten nicht geladen werden.');
return;
}
if (!friends.length) {
UI.toast.info('Du hast noch keine Freunde hinzugefügt.');
return;
}
const friendRows = friends.map(f => {
const initial = (f.name || '?')[0].toUpperCase();
return `<div class="rk-friend-row" data-id="${f.id}" data-name="${_esc(f.name || 'Anonym')}"
style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);
cursor:pointer;border-radius:var(--radius-md);transition:background .15s"
onmouseover="this.style.background='var(--c-surface-2)'"
onmouseout="this.style.background=''">
<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary);
color:#fff;display:flex;align-items:center;justify-content:center;
font-weight:600;flex-shrink:0">${_esc(initial)}</div>
<span>${_esc(f.name || 'Anonym')}</span>
</div>`;
}).join('');
const body = `<div id="rk-friend-list">${friendRows}</div>`;
const footer = `<button type="button" class="btn btn-ghost" id="rsf-cancel">Abbrechen</button>`;
UI.modal.open({
title: `${UI.icon('chat-circle-dots')} An Freund senden`,
body,
footer,
});
document.getElementById('rsf-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('rk-friend-list')?.addEventListener('click', async e => {
const row = e.target.closest('.rk-friend-row');
if (!row) return;
const partnerId = parseInt(row.dataset.id, 10);
const partnerName = row.dataset.name;
try {
const conv = await API.chat.start(partnerId);
const convId = conv.id;
const text = `Ich habe eine Route für dich: ${route.name}\n${shareUrl}`;
await API.chat.send(convId, text);
UI.modal.close();
UI.toast.success(`Gesendet an ${partnerName}`);
} catch (err) {
UI.toast.error('Senden fehlgeschlagen: ' + (err.message || 'Unbekannter Fehler'));
}
});
}
return { init, refresh, onDogChange };
})();

View file

@ -18,13 +18,17 @@ window.Page_sitting = (() => {
// ----------------------------------------------------------
// State
// ----------------------------------------------------------
let _container = null;
let _state = null;
let _tab = 'suchen'; // suchen | profil | anfragen
let _sitters = [];
let _mySitter = null;
let _myRequests = [];
let _inbox = [];
let _container = null;
let _state = null;
let _tab = 'suchen'; // suchen | profil | anfragen | matching
let _sitters = [];
let _mySitter = null;
let _myRequests = [];
let _inbox = [];
// Matching-State
let _matchResults = null; // null = noch nicht geladen
let _matchLoading = false;
let _myServiceOffer = null; // eigenes Angebot (type='sitting')
// ----------------------------------------------------------
// init
@ -46,6 +50,7 @@ window.Page_sitting = (() => {
<div class="sitting-layout">
<div class="sitting-tabs by-tabs" id="sit-tabs">
<button class="by-tab active" data-sit-tab="suchen">${UI.icon('magnifying-glass')} Sitter finden</button>
<button class="by-tab" data-sit-tab="matching">${UI.icon('users')} Anbieter</button>
${_state.user ? `
<button class="by-tab" data-sit-tab="profil">${UI.icon('user')} Mein Profil</button>
<button class="by-tab" data-sit-tab="anfragen">${UI.icon('bell')} Anfragen</button>
@ -70,6 +75,7 @@ window.Page_sitting = (() => {
tasks.push(API.sitting.me());
tasks.push(API.sitting.requests());
tasks.push(API.sitting.inbox());
tasks.push(API.services.me());
}
try {
@ -78,6 +84,8 @@ window.Page_sitting = (() => {
_mySitter = results[1]?.status === 'fulfilled' ? results[1].value : null;
_myRequests = results[2]?.status === 'fulfilled' ? results[2].value : [];
_inbox = results[3]?.status === 'fulfilled' ? results[3].value : [];
const myOffers = results[4]?.status === 'fulfilled' ? results[4].value : [];
_myServiceOffer = myOffers?.find(o => o.type === 'sitting') || null;
} catch {}
_renderTab();
@ -92,6 +100,7 @@ window.Page_sitting = (() => {
if (_tab === 'suchen') _renderSuchen(content);
if (_tab === 'profil') _renderProfil(content);
if (_tab === 'anfragen') _renderAnfragen(content);
if (_tab === 'matching') _renderMatching(content);
}
// ---- Tab: Sitter suchen ----
@ -441,6 +450,239 @@ window.Page_sitting = (() => {
});
}
// ----------------------------------------------------------
// Tab: Anbieter in deiner Nähe (service_offers Matching)
// ----------------------------------------------------------
function _renderMatching(el) {
const offerActive = _myServiceOffer?.aktiv;
const offerDesc = _myServiceOffer?.beschreibung || '';
const offerPreis = _myServiceOffer?.preis_pro_tag ?? '';
el.innerHTML = `
<div class="svc-matching-layout">
<!-- Eigenes Angebot -->
<div class="svc-own-offer by-card">
<div class="svc-own-offer-header">
<span class="svc-own-offer-title">${UI.icon('handshake')} Mein Angebot</span>
${_state.user ? `
<label class="svc-toggle" title="${offerActive ? 'Angebot deaktivieren' : 'Angebot aktivieren'}">
<input type="checkbox" id="svc-offer-toggle" ${offerActive ? 'checked' : ''}>
<span class="svc-toggle-slider"></span>
</label>
` : `<span class="svc-login-hint">Zum Anbieten bitte anmelden</span>`}
</div>
${_state.user ? `
<form id="svc-offer-form" class="svc-offer-form ${offerActive ? '' : 'svc-offer-form--hidden'}">
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="2"
placeholder="Was bietest du an? Erfahrungen, besondere Stärken…">${UI.escape(offerDesc)}</textarea>
</div>
<div class="form-row-2">
<div class="form-group">
<label class="form-label">Preis/Tag (, optional)</label>
<input class="form-control" type="number" step="1" min="0" name="preis_pro_tag"
value="${offerPreis}">
</div>
<div class="form-group">
<label class="form-label">Umkreis (km)</label>
<input class="form-control" type="number" min="1" max="100" name="radius_km"
value="${_myServiceOffer?.radius_km ?? 10}">
</div>
</div>
<div class="svc-offer-actions">
<button type="button" class="btn btn-secondary btn-sm" id="svc-gps-btn">
${UI.icon('map-pin')} Position
</button>
<input type="hidden" name="lat" id="svc-lat" value="${_myServiceOffer?.lat || ''}">
<input type="hidden" name="lon" id="svc-lon" value="${_myServiceOffer?.lon || ''}">
<button type="submit" class="btn btn-primary btn-sm">
${UI.icon('floppy-disk')} Speichern
</button>
</div>
</form>
` : ''}
</div>
<!-- Anbieter finden -->
<div class="svc-search-section">
<div class="svc-search-header">
<span class="by-section-label" style="margin:0">${UI.icon('magnifying-glass')} Anbieter in deiner Nähe</span>
<button class="btn btn-primary btn-sm" id="svc-find-btn">
${UI.icon('map-pin')} Suchen
</button>
</div>
<div id="svc-results">
<p class="svc-hint">Klicke auf "Suchen" um Hundesitting-Anbieter in deiner Nähe zu finden.</p>
</div>
</div>
</div>
`;
// Toggle
document.getElementById('svc-offer-toggle')?.addEventListener('change', async e => {
const form = document.getElementById('svc-offer-form');
if (e.target.checked) {
form?.classList.remove('svc-offer-form--hidden');
} else {
form?.classList.add('svc-offer-form--hidden');
if (_myServiceOffer) {
try {
await API.services.deactivate(_myServiceOffer.id);
_myServiceOffer = { ..._myServiceOffer, aktiv: 0 };
UI.toast('Angebot deaktiviert.');
} catch (err) { UI.toast(err.message, 'error'); }
}
}
});
// GPS
document.getElementById('svc-gps-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('svc-gps-btn');
btn.disabled = true;
try {
const pos = await API.getLocation();
document.getElementById('svc-lat').value = pos.lat.toFixed(6);
document.getElementById('svc-lon').value = pos.lon.toFixed(6);
UI.toast('Position gespeichert.');
} catch { UI.toast('GPS nicht verfügbar.', 'error'); }
btn.disabled = false;
});
// Formular speichern
document.getElementById('svc-offer-form')?.addEventListener('submit', async e => {
e.preventDefault();
const form = e.target;
const fd = new FormData(form);
const submitBtn = form.querySelector('[type="submit"]');
// Wenn noch keine Position gespeichert, GPS holen
if (!fd.get('lat') || !fd.get('lon')) {
try {
const pos = await API.getLocation();
document.getElementById('svc-lat').value = pos.lat.toFixed(6);
document.getElementById('svc-lon').value = pos.lon.toFixed(6);
fd.set('lat', pos.lat.toFixed(6));
fd.set('lon', pos.lon.toFixed(6));
} catch {
UI.toast('Bitte GPS-Position ermitteln.', 'error');
return;
}
}
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '…'; }
try {
const payload = {
type: 'sitting',
beschreibung: fd.get('beschreibung') || null,
preis_pro_tag: fd.get('preis_pro_tag') ? parseFloat(fd.get('preis_pro_tag')) : null,
lat: parseFloat(fd.get('lat')),
lon: parseFloat(fd.get('lon')),
radius_km: parseInt(fd.get('radius_km')) || 10,
};
_myServiceOffer = await API.services.upsert(payload);
UI.toast('Angebot gespeichert!');
} catch (err) {
UI.toast(err.message, 'error');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = `${UI.icon('floppy-disk')} Speichern`;
}
}
});
// Suche
document.getElementById('svc-find-btn')?.addEventListener('click', _searchProviders);
}
async function _searchProviders() {
if (_matchLoading) return;
_matchLoading = true;
const btn = document.getElementById('svc-find-btn');
if (btn) { btn.disabled = true; btn.innerHTML = `${UI.icon('spinner')} Suche…`; }
const resultsEl = document.getElementById('svc-results');
let pos = null;
try {
pos = await API.getLocation();
} catch {
UI.toast('GPS nicht verfügbar — Suche ohne Entfernung.', 'error');
}
try {
const offers = await API.services.list('sitting', pos?.lat ?? null, pos?.lon ?? null, 50);
_matchResults = offers;
_renderMatchResults(resultsEl, pos);
} catch (err) {
UI.toast(err.message, 'error');
if (resultsEl) resultsEl.innerHTML = `<p class="svc-hint">Fehler beim Laden.</p>`;
}
_matchLoading = false;
if (btn) {
btn.disabled = false;
btn.innerHTML = `${UI.icon('map-pin')} Erneut suchen`;
}
}
function _renderMatchResults(el, pos) {
if (!el) return;
const list = _matchResults || [];
// Eigene Angebote ausblenden
const filtered = list.filter(o => o.user_id !== _state.user?.id);
if (!filtered.length) {
el.innerHTML = `<p class="svc-hint">Keine Anbieter in deiner Nähe gefunden.</p>`;
return;
}
el.innerHTML = `
<div class="svc-results-list">
${filtered.map(o => _serviceCardHTML(o)).join('')}
</div>
`;
}
function _serviceCardHTML(o) {
const dist = o.distanz_km != null ? `${o.distanz_km} km entfernt` : '';
const preis = o.preis_pro_tag != null ? `${o.preis_pro_tag.toFixed(0)} €/Tag` : 'Preis anfragen';
return `
<div class="svc-card">
<div class="svc-card-avatar">${UI.icon('paw-print')}</div>
<div class="svc-card-body">
<div class="svc-card-name">${UI.escape(o.anbieter_name || `Nutzer #${o.user_id}`)}</div>
${dist ? `<div class="svc-card-dist">${UI.icon('map-pin')} ${dist}</div>` : ''}
${o.beschreibung ? `<div class="svc-card-desc">${UI.escape(o.beschreibung)}</div>` : ''}
</div>
<div class="svc-card-side">
<div class="svc-card-price">${preis}</div>
<button class="btn btn-primary btn-sm" data-svc-chat="${o.user_id}">
${UI.icon('chat-circle')} Kontakt
</button>
</div>
</div>
`;
}
async function _openChatWithProvider(userId) {
if (!_state.user) {
UI.toast('Bitte zuerst anmelden.', 'error');
App.navigate('settings');
return;
}
try {
const { conversation_id } = await API.chat.start(userId);
App.navigate('chat', true, { conversation_id });
} catch (err) {
UI.toast(err.message, 'error');
}
}
// ----------------------------------------------------------
// Click-Handler
// ----------------------------------------------------------
@ -467,6 +709,13 @@ window.Page_sitting = (() => {
return;
}
// Anbieter kontaktieren
const chatBtn = e.target.closest('[data-svc-chat]');
if (chatBtn) {
_openChatWithProvider(parseInt(chatBtn.dataset.svcChat));
return;
}
// Anfragen-Aktionen
const acceptBtn = e.target.closest('[data-sit-accept]');
const declineBtn = e.target.closest('[data-sit-decline]');

View file

@ -268,6 +268,37 @@ const UI = (() => {
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// HELP TOOLTIP — inline ? Badge mit Klick-Tooltip
// ----------------------------------------------------------
function help(text) {
return `<button class="by-help-btn" data-help="${escape(text)}" aria-label="Hilfe" type="button">
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px">
<use href="/icons/phosphor.svg#question"></use>
</svg>
</button>`;
}
// Event-Delegation für Help-Tooltips — einmalig registrieren
document.addEventListener('click', e => {
const btn = e.target.closest('.by-help-btn');
if (!btn) {
document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove());
return;
}
e.stopPropagation();
document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove());
const tip = document.createElement('div');
tip.className = 'by-help-tooltip';
tip.textContent = btn.dataset.help;
document.body.appendChild(tip);
const r = btn.getBoundingClientRect();
tip.style.top = (r.bottom + window.scrollY + 6) + 'px';
tip.style.left = Math.max(8, r.left + window.scrollX - tip.offsetWidth / 2 + r.width / 2) + 'px';
const maxL = window.innerWidth - tip.offsetWidth - 8;
if (parseFloat(tip.style.left) > maxL) tip.style.left = maxL + 'px';
});
// Öffentliche API
return {
toast, modal,
@ -276,7 +307,7 @@ const UI = (() => {
emptyState, time,
setupPhotoPreview, scrollTop, skeleton,
icon: _svgIcon,
escape,
escape, help,
};
})();

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v156';
const CACHE_VERSION = 'by-v157';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten