Feature: User-Feedback, Regen-Uhrzeit im Wetter-Chip, Admin-Karten klickbar (SW by-v833)

- Feedback-Modal im Settings (Kategorie + Text → E-Mail an support@banyaro.app)
- Wetter-Chip (Karte + Gassi-Score): zeigt nächste Regenstunde ab ≥20% Wahrscheinlichkeit
- Gassi-Score-Chip: zweizeilige Wetter-Info, linksbündig, volle Chipbreite
- Admin-Übersicht: Stat-Karten anklickbar → navigiert direkt zum jeweiligen Tab
- ui.js: visualViewport-Listener hebt Modal über Tastatur (alle Modals)
- api.js: Pydantic v2 Array-Detail korrekt als Fehlermeldung extrahiert
- map.js: Wetter-Fallback über watchPosition wenn getCurrentPosition scheitert
- Update-Loop-Fix: index.html ?v= synchron mit APP_VER halten (alle 4 Stellen)
This commit is contained in:
rene 2026-05-10 12:52:55 +02:00
parent d18c592ef0
commit 70af387147
12 changed files with 211 additions and 42 deletions

View file

@ -58,7 +58,10 @@ const API = (() => {
try { data = await response.json(); } catch { data = null; }
if (!response.ok) {
const message = data?.detail || data?.message || `Fehler ${response.status}`;
const _d = data?.detail;
const message = (typeof _d === 'string' ? _d
: Array.isArray(_d) ? (_d[0]?.msg || 'Ungültige Eingabe')
: null) || data?.message || `Fehler ${response.status}`;
const isSwOffline = response.status === 503 && message.startsWith('Offline');
// Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '826'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '833'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen

View file

@ -534,22 +534,22 @@ window.Page_admin = (() => {
};
el.innerHTML = `
<div class="adm-stats-grid">
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')}
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')}
${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)')}
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')}
${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)')}
${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')}
${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)')}
${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)')}
${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)')}
<div class="adm-stats-grid" id="adm-overview-grid">
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)', 'nutzer')}
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)', 'nutzer')}
${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)', 'nutzer')}
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)', 'nutzer')}
${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)','forum')}
${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)', 'moderation')}
${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'moderation')}
${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'nutzer')}
${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)', 'system')}
${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)','system')}
${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')}
${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')}
${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')}
${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)')}
${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)')}
${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)', 'system')}
${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)', 'system')}
</div>
<div class="card" style="padding:var(--space-4)">
@ -705,11 +705,19 @@ window.Page_admin = (() => {
</p>
</div>
`;
el.querySelector('#adm-overview-grid')?.addEventListener('click', e => {
const card = e.target.closest('[data-adm-tab]');
if (!card) return;
const tab = card.dataset.admTab;
_container.querySelector(`#adm-tabs .by-tab[data-tab="${tab}"]`)?.click();
});
}
function _statCard(icon, label, value, color) {
function _statCard(icon, label, value, color, tab = null) {
const clickable = tab ? `data-adm-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer"` : `style="padding:var(--space-4);text-align:center"`;
return `
<div class="card" style="padding:var(--space-4);text-align:center">
<div class="card" ${clickable}>
<svg class="ph-icon" style="width:24px;height:24px;color:${color};margin-bottom:var(--space-2)"
aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>

View file

@ -11,6 +11,7 @@ window.Page_map = (() => {
let _map = null;
let _leafletLoaded = false;
let _userPos = null;
let _weatherLoaded = false;
let _placingMarker = false;
let _tempMarker = null;
@ -147,6 +148,7 @@ window.Page_map = (() => {
_userPos = pos;
if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; }
_map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 });
_weatherLoaded = true;
_loadWeather(pos.lat, pos.lon);
}).catch(() => {
const btn = document.getElementById('map-locate-btn');
@ -373,6 +375,7 @@ window.Page_map = (() => {
pos => {
const { latitude: lat, longitude: lon, accuracy: acc } = pos.coords;
_userPos = { lat, lon };
if (!_weatherLoaded) { _weatherLoaded = true; _loadWeather(lat, lon); }
if (_locationMarker) {
_locationMarker.setLatLng([lat, lon]);
_locationAccuracy?.setLatLng([lat, lon]).setRadius(acc);
@ -1628,7 +1631,9 @@ window.Page_map = (() => {
const w = await API.weather.get(lat, lon);
const temp = w.temp_c != null ? `${Math.round(w.temp_c)}°` : '';
const icon = `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:-2px"><use href="/icons/phosphor.svg#${w.icon}"></use></svg>`;
const regen = w.precip_prob != null ? ` · 💧 ${w.precip_prob}%` : '';
const regen = w.precip_prob != null
? (w.next_rain_time ? ` · 💧 ${w.precip_prob}% ab ${w.next_rain_time}` : ` · 💧 ${w.precip_prob}%`)
: '';
let zecken = '';
if (w.zecken_warnung) {
const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E';

View file

@ -269,6 +269,12 @@ window.Page_settings = (() => {
<span>Hilfe &amp; FAQ</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-feedback-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#chat-dots"></use></svg>
<span>Feedback geben</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? `
<div style="margin:var(--space-3) 0;padding:var(--space-3) var(--space-4);
background:rgba(196,132,58,0.1);border-radius:var(--radius-md);
@ -790,6 +796,52 @@ window.Page_settings = (() => {
App.navigate('hilfe');
});
document.getElementById('settings-feedback-btn')?.addEventListener('click', () => {
const sel = (id) => document.getElementById(id);
const inputStyle = 'width:100%;padding:10px 12px;border:1.5px solid var(--c-border);border-radius:var(--radius-md);background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box';
UI.modal.open({
title: 'Feedback geben',
body: `
<form id="feedback-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Kategorie</label>
<select id="feedback-kat" name="kategorie" style="${inputStyle}">
<option value="idee">💡 Idee / Wunsch</option>
<option value="bug">🐛 Bug / Fehler</option>
<option value="lob">🎉 Lob</option>
<option value="sonstiges">💬 Sonstiges</option>
</select>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Deine Nachricht</label>
<textarea id="feedback-text" name="text" rows="5" maxlength="2000"
placeholder="Was möchtest du uns mitteilen?"
style="${inputStyle};resize:vertical"></textarea>
</div>
</form>
`,
footer: `
<div class="w3-btn-stack">
<button type="submit" form="feedback-form" id="feedback-submit-btn" class="btn btn-primary" style="width:100%">Absenden</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
</div>
`,
});
sel('feedback-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = sel('feedback-submit-btn');
const kat = sel('feedback-kat')?.value;
const text = sel('feedback-text')?.value?.trim();
if (!text) { UI.toast.error('Bitte schreib etwas.'); return; }
await UI.asyncButton(btn, async () => {
await API.post('/feedback', { kategorie: kat, text });
UI.modal.close?.();
UI.toast.success('Vielen Dank für dein Feedback!');
});
});
});
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title : 'Abmelden?',

View file

@ -79,15 +79,34 @@ const UI = (() => {
document.getElementById('modal-container').appendChild(overlay);
document.documentElement.classList.add('modal-open');
_current = { overlay, onClose };
// Tastatur auf Mobilgeräten: Modal nach oben schieben wenn Keyboard erscheint
let _vvCleanup = null;
const vv = window.visualViewport;
if (vv) {
const adjust = () => {
const kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
overlay.style.paddingBottom = (kb + 16) + 'px';
};
vv.addEventListener('resize', adjust);
vv.addEventListener('scroll', adjust);
_vvCleanup = () => {
vv.removeEventListener('resize', adjust);
vv.removeEventListener('scroll', adjust);
overlay.style.paddingBottom = '';
};
}
_current = { overlay, onClose, _vvCleanup };
return overlay.querySelector('.modal');
}
function close() {
if (!_current) return;
const { onClose } = _current;
const { onClose, _vvCleanup } = _current;
onClose?.();
_vvCleanup?.();
_current.overlay.remove();
document.documentElement.classList.remove('modal-open');
_current = null;

View file

@ -1097,7 +1097,10 @@ window.Worlds = (() => {
<span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span>
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
</div>
${w ? `<span style="font-size:9px;color:rgba(255,255,255,0.75);font-weight:500;margin-top:1px;white-space:nowrap">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</span>` : ''}
${w ? `<div style="font-size:9px;color:rgba(255,255,255,0.75);font-weight:500;margin-top:2px;line-height:1.5">
<div>${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>
${w.next_rain_time ? `<div style="color:rgba(255,255,255,0.5)">ab ${w.next_rain_time} Uhr</div>` : ''}
</div>` : ''}
</div>
</div>
</div>