Sprint 18: Notification Center, Routen entdecken, Onboarding, Admin-Erweiterungen

- Notifications: History-Tabelle, /api/notifications Endpoints, push.py schreibt in DB
- Notifications: Page (notifications.js) mit Badge, Typen-Icons, gelesen-Markierung
- Routen: Entdecken-Modus mit Ersteller-Anzeige, Nearby-Filter, Mine/Discover Toggle
- Onboarding: Willkommens-Modal nach Registrierung, Push-Angebot nach Login
- Admin: Scheduler-Tab (Jobs anzeigen + manuell triggern), System-Health (DB/Disk/Uptime)
- Admin: Audit-Log (wer hat was wann gemacht), erweiterte Stats (Push-Abos, aktive User, Routen)
- SW: by-v152, APP_VER 125
This commit is contained in:
rene 2026-04-17 23:21:48 +02:00
parent 5927d384bf
commit 92620c2c52
14 changed files with 1035 additions and 46 deletions

View file

@ -433,6 +433,17 @@ const API = (() => {
snapshot: () => get('/widget/snapshot'),
};
// ----------------------------------------------------------
// NOTIFICATIONS
// ----------------------------------------------------------
const notifications = {
list() { return get('/notifications'); },
unreadCount() { return get('/notifications/unread-count'); },
readAll() { return patch('/notifications/read-all', {}); },
read(id) { return patch(`/notifications/${id}/read`, {}); },
delete(id) { return del(`/notifications/${id}`); },
};
const importData = {
notestation(dogId, file) {
const fd = new FormData();
@ -465,7 +476,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,
friends, chat, webcal, importData, sharing, widget, notifications,
subscribeToPush, getLocation,
APIError,
};

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '123'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '124'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {
@ -56,7 +56,8 @@ const App = (() => {
admin: { title: 'Admin', module: null, requiresAuth: true },
impressum: { title: 'Impressum', module: null },
datenschutz: { title: 'Datenschutz', module: null },
widget: { title: 'Widget', module: null, requiresAuth: true },
widget: { title: 'Widget', module: null, requiresAuth: true },
notifications: { title: 'Benachrichtigungen', module: null, requiresAuth: true },
};
// ----------------------------------------------------------
@ -409,6 +410,26 @@ const App = (() => {
adminItem.style.display = isMod ? '' : 'none';
}
await _loadDogs();
// Eingeloggter User ohne Hund (z.B. nach Reload) → direkt zur Hund-Anlage
if (state.dogs.length === 0) {
navigate('dog-profile');
}
_updateNotifBadge();
// Badge alle 60s aktualisieren
setInterval(_updateNotifBadge, 60_000);
}
async function _updateNotifBadge() {
if (!state.user) return;
try {
const { count } = await API.notifications.unreadCount();
const badge = document.getElementById('notif-badge');
if (!badge) return;
badge.textContent = count;
badge.style.display = count > 0 ? '' : 'none';
} catch { /* ignorieren */ }
}
function _onLoggedOut() {
@ -446,6 +467,50 @@ const App = (() => {
} catch { /* kein Hund vorhanden */ }
}
// ----------------------------------------------------------
// ONBOARDING — Willkommens-Modal für neue User
// ----------------------------------------------------------
function _showOnboardingModal() {
UI.modal.open({
title: `${UI.icon('paw-print')} Willkommen bei Ban Yaro!`,
body: `
<div style="display:flex;flex-direction:column;align-items:center;
gap:var(--space-4);text-align:center;padding:var(--space-2) 0">
<div style="width:64px;height:64px;border-radius:50%;
background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center">
<svg style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#dog"></use>
</svg>
</div>
<div style="max-width:300px">
<p style="margin:0 0 var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary);line-height:1.6">
Schön, dass du dabei bist! Ban Yaro hilft dir, alles rund um
deinen Hund im Blick zu behalten Spaziergänge, Gesundheit,
Termine und vieles mehr.
</p>
<p style="margin:0;font-size:var(--text-sm);
color:var(--c-text-secondary);line-height:1.6">
Fang jetzt an und leg ein Profil für deinen Hund an.
</p>
</div>
</div>
`,
footer: `
<button type="button" class="btn btn-primary" id="onboarding-start-btn">
${UI.icon('dog')} Meinen ersten Hund anlegen
</button>
<button type="button" class="btn btn-ghost" data-modal-close>Später</button>
`,
});
document.getElementById('onboarding-start-btn')?.addEventListener('click', () => {
UI.modal.close();
navigate('dog-profile');
});
}
function _notifyDogChange() {
Object.values(pages).forEach(p => p.module?.onDogChange?.(state.activeDog));
}
@ -646,7 +711,9 @@ const App = (() => {
// (andere Module können App.state, App.navigate etc. nutzen)
// ----------------------------------------------------------
return { init, navigate, state, setActiveDog, renderDogSwitcher: _renderDogSwitcher,
getInstallPrompt: () => _installPrompt, requireAuth };
getInstallPrompt: () => _installPrompt, requireAuth,
showOnboarding: _showOnboardingModal,
updateNotifBadge: _updateNotifBadge };
})();

View file

@ -10,9 +10,12 @@ window.Page_admin = (() => {
let _tab = 'uebersicht';
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'chart-bar' },
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'uebersicht', label: 'Übersicht', icon: 'chart-bar' },
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'forum', label: 'Forum & Meldungen',icon: 'chat-circle-dots' },
{ id: 'system', label: 'System', icon: 'cpu' },
{ id: 'jobs', label: 'Jobs', icon: 'timer' },
{ id: 'audit', label: 'Audit-Log', icon: 'list-bullets' },
];
// ------------------------------------------------------------------
@ -76,6 +79,9 @@ window.Page_admin = (() => {
case 'uebersicht': await _renderStats(el); break;
case 'nutzer': await _renderUsers(el); break;
case 'forum': await _renderForum(el); break;
case 'system': await _renderSystem(el); break;
case 'jobs': await _renderJobs(el); break;
case 'audit': await _renderAudit(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -90,13 +96,18 @@ window.Page_admin = (() => {
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:var(--space-3);
margin-bottom:var(--space-5)">
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')}
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')}
${_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('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('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('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)')}
${_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)')}
</div>
<div class="card" style="padding:var(--space-4)">
@ -532,6 +543,204 @@ window.Page_admin = (() => {
} catch (e) { UI.toast.error(e.message); }
}
// ------------------------------------------------------------------
// TAB: SYSTEM
// ------------------------------------------------------------------
async function _renderSystem(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-sys-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-sys-cards">Lade</div>
`;
el.querySelector('#adm-sys-refresh').addEventListener('click', () => _loadSystemCards(el.querySelector('#adm-sys-cards')));
await _loadSystemCards(el.querySelector('#adm-sys-cards'));
}
async function _loadSystemCards(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const s = await API.get('/admin/system');
const diskUsedGb = s.disk_total_gb - s.disk_free_gb;
const diskPct = s.disk_total_gb > 0 ? Math.round(diskUsedGb / s.disk_total_gb * 100) : 0;
const uptime = _formatUptime(s.uptime_seconds);
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:var(--space-3)">
${_statCard('database', 'Datenbank', s.db_size_mb.toFixed(1) + ' MB', 'var(--c-primary)')}
${_statCard('image', 'Media-Ordner', s.media_size_mb.toFixed(1) + ' MB','var(--c-text-secondary)')}
${_statCard('timer', 'Uptime', uptime, 'var(--c-success)')}
${_statCard('hard-drive','Disk frei', s.disk_free_gb.toFixed(1) + ' GB','diskPct > 85 ? "var(--c-danger)" : "var(--c-text-secondary)"')}
</div>
<div class="card" style="margin-top:var(--space-4);padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Disk-Auslastung</div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="flex:1;height:8px;background:var(--c-surface-2);border-radius:4px;overflow:hidden">
<div style="height:100%;width:${diskPct}%;background:${diskPct > 85 ? 'var(--c-danger)' : diskPct > 65 ? '#f59e0b' : 'var(--c-success)'};border-radius:4px;transition:width .3s"></div>
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);white-space:nowrap">
${diskPct}% · ${diskUsedGb.toFixed(1)} / ${s.disk_total_gb.toFixed(1)} GB
</div>
</div>
<div style="margin-top:var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
Python ${_esc(s.python_version)}
</div>
</div>
`;
}
function _formatUptime(secs) {
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}min`;
return `${m}min`;
}
// ------------------------------------------------------------------
// TAB: JOBS
// ------------------------------------------------------------------
async function _renderJobs(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-jobs-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-jobs-list">Lade</div>
`;
el.querySelector('#adm-jobs-refresh').addEventListener('click', () => _loadJobs(el.querySelector('#adm-jobs-list')));
await _loadJobs(el.querySelector('#adm-jobs-list'));
}
async function _loadJobs(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const jobs = await API.get('/admin/scheduler/jobs');
if (!jobs.length) {
el.innerHTML = _emptyState('timer', 'Keine Jobs', 'Der Scheduler hat keine registrierten Jobs.');
return;
}
el.innerHTML = `
<div class="card" style="overflow:hidden">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Job</th>
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Nächster Lauf</th>
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Trigger</th>
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)"></th>
</tr>
</thead>
<tbody>
${jobs.map((j, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(j.name)}
<div style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:normal">${_esc(j.id)}</div>
</td>
<td style="padding:var(--space-3) var(--space-4);color:var(--c-text-secondary)">
${j.next_run_time ? _formatDateTime(j.next_run_time) : '<span style="color:var(--c-text-muted)">—</span>'}
</td>
<td style="padding:var(--space-3) var(--space-4);color:var(--c-text-muted);font-size:var(--text-xs);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(j.trigger)}
</td>
<td style="padding:var(--space-3) var(--space-4);text-align:right">
<button class="btn btn-sm btn-ghost adm-job-trigger" data-id="${_esc(j.id)}" data-name="${_esc(j.name)}"
title="Jetzt ausführen" style="color:var(--c-primary)">
${UI.icon('play')}
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
el.querySelectorAll('.adm-job-trigger').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.post(`/admin/scheduler/trigger/${encodeURIComponent(btn.dataset.id)}`, {});
UI.toast.success(`Job "${btn.dataset.name}" wird ausgeführt.`);
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Auslösen des Jobs.');
} finally {
btn.disabled = false;
}
});
});
}
function _formatDateTime(iso) {
try {
const d = new Date(iso);
return d.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
} catch { return iso; }
}
// ------------------------------------------------------------------
// TAB: AUDIT-LOG
// ------------------------------------------------------------------
async function _renderAudit(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-audit-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-audit-list">Lade</div>
`;
el.querySelector('#adm-audit-refresh').addEventListener('click', () => _loadAudit(el.querySelector('#adm-audit-list')));
await _loadAudit(el.querySelector('#adm-audit-list'));
}
async function _loadAudit(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const rows = await API.get('/admin/audit?limit=50');
if (!rows.length) {
el.innerHTML = _emptyState('list-bullets', 'Keine Einträge', 'Noch keine Admin-Aktionen protokolliert.');
return;
}
el.innerHTML = `
<div class="card" style="overflow:hidden">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Wann</th>
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Admin</th>
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Aktion</th>
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Ziel</th>
</tr>
</thead>
<tbody>
${rows.map((r, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td style="padding:var(--space-2) var(--space-4);color:var(--c-text-muted);white-space:nowrap;font-size:var(--text-xs)">
${_formatDateTime(r.created_at)}
</td>
<td style="padding:var(--space-2) var(--space-4);color:var(--c-text)">
${_esc(r.admin_name || '—')}
</td>
<td style="padding:var(--space-2) var(--space-4)">
<span style="font-size:var(--text-xs);padding:2px 7px;border-radius:3px;
background:var(--c-surface-2);color:var(--c-text-secondary);font-family:monospace">
${_esc(r.action)}
</span>
${r.detail ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_esc(r.detail)}</div>` : ''}
</td>
<td style="padding:var(--space-2) var(--space-4);color:var(--c-text-secondary);font-size:var(--text-xs)">
${_esc(r.target || '—')}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
// ------------------------------------------------------------------
// HELPERS
// ------------------------------------------------------------------

View file

@ -0,0 +1,229 @@
/* BAN YARO — Notification Center */
window.Page_notifications = (() => {
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
/** Relativer Zeitstempel: "vor 2h", "vor 3 Tagen", etc. */
function _relTime(isoStr) {
if (!isoStr) return '';
const diff = Date.now() - new Date(isoStr + (isoStr.includes('Z') ? '' : 'Z')).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min.`;
const h = Math.floor(min / 60);
if (h < 24) return `vor ${h}h`;
const d = Math.floor(h / 24);
if (d < 30) return `vor ${d} Tag${d !== 1 ? 'en' : ''}`;
const mo = Math.floor(d / 30);
return `vor ${mo} Monat${mo !== 1 ? 'en' : ''}`;
}
/** Phosphor-Icon-Name je nach Notification-Typ */
function _iconForType(type) {
switch (type) {
case 'chat_message': return 'chat-circle';
case 'friend_request': return 'user-plus';
case 'health_reminder': return 'first-aid';
case 'milestone': return 'star';
case 'poison_alert': return 'warning';
default: return 'bell';
}
}
/** Rendert eine einzelne Notification als HTML-String */
function _renderItem(n) {
const unread = !n.read_at;
const iconName = unread ? _iconForType(n.type) : 'bell';
const cls = ['notif-item', unread ? 'notif-unread' : ''].filter(Boolean).join(' ');
return `
<div class="${cls}" data-id="${n.id}" data-page="${UI.escape((n.data && JSON.parse(n.data || '{}').page) || '')}">
<span class="notif-icon">${UI.icon(iconName)}</span>
<div class="notif-content">
<div class="notif-title">${UI.escape(n.title)}</div>
${n.body ? `<div class="notif-body">${UI.escape(n.body)}</div>` : ''}
<div class="notif-time">${_relTime(n.created_at)}</div>
</div>
<button class="notif-del-btn icon-btn" data-del="${n.id}" title="Löschen"
aria-label="Benachrichtigung löschen">
${UI.icon('x')}
</button>
</div>`;
}
// ----------------------------------------------------------
// init
// ----------------------------------------------------------
async function init(container, appState, params) {
container.innerHTML = `
<div class="page-header">
<h2>${UI.icon('bell')} Benachrichtigungen</h2>
<button class="btn btn-sm btn-ghost" id="notif-read-all">Alle gelesen</button>
</div>
<div id="notif-list" class="notif-list">
<div class="loading-spinner"></div>
</div>`;
_addStyles();
// Daten laden
let items = [];
try {
items = await API.notifications.list();
} catch (e) {
document.getElementById('notif-list').innerHTML =
`<div class="empty-state">${UI.icon('warning')} Fehler beim Laden.</div>`;
return;
}
_render(items);
// "Alle gelesen"-Button
document.getElementById('notif-read-all')?.addEventListener('click', async () => {
try {
await API.notifications.readAll();
// Alle als gelesen markieren (lokal)
items = items.map(n => ({ ...n, read_at: new Date().toISOString() }));
_render(items);
} catch (e) {
UI.toast?.('Fehler beim Markieren.', 'error');
}
});
}
// ----------------------------------------------------------
// Render-Helfer
// ----------------------------------------------------------
function _render(items) {
const list = document.getElementById('notif-list');
if (!list) return;
if (!items || items.length === 0) {
list.innerHTML = `
<div class="empty-state">
${UI.icon('bell-slash')}
<p>Keine Benachrichtigungen</p>
</div>`;
return;
}
list.innerHTML = items.map(_renderItem).join('');
// Klick auf Notification: als gelesen + ggf. navigieren
list.querySelectorAll('.notif-item').forEach(el => {
el.addEventListener('click', async (e) => {
// Löschen-Button nicht doppelt behandeln
if (e.target.closest('.notif-del-btn')) return;
const id = parseInt(el.dataset.id, 10);
const page = el.dataset.page;
// Optisch sofort als gelesen markieren
el.classList.remove('notif-unread');
try { await API.notifications.read(id); } catch (_) {}
if (page && window.App?.navigate) {
window.App.navigate(page);
}
});
});
// Löschen-Buttons
list.querySelectorAll('.notif-del-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = parseInt(btn.dataset.del, 10);
try {
await API.notifications.delete(id);
btn.closest('.notif-item')?.remove();
if (!list.querySelector('.notif-item')) {
list.innerHTML = `
<div class="empty-state">
${UI.icon('bell-slash')}
<p>Keine Benachrichtigungen</p>
</div>`;
}
} catch (_) {}
});
});
}
// ----------------------------------------------------------
// Inline-Styles (einmalig einfügen)
// ----------------------------------------------------------
function _addStyles() {
if (document.getElementById('notif-styles')) return;
const style = document.createElement('style');
style.id = 'notif-styles';
style.textContent = `
.notif-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4) 0;
}
.notif-item {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--c-surface);
border: 1px solid var(--c-border-light);
cursor: pointer;
transition: background var(--transition-fast);
}
.notif-item:hover {
background: var(--c-surface-2);
}
.notif-item.notif-unread {
border-left: 3px solid var(--c-primary);
background: var(--c-primary-subtle);
}
.notif-item.notif-unread .notif-title {
font-weight: var(--weight-semibold);
}
.notif-icon {
flex-shrink: 0;
color: var(--c-primary);
margin-top: 2px;
}
.notif-content {
flex: 1;
min-width: 0;
}
.notif-title {
font-size: var(--text-sm);
color: var(--c-text);
line-height: var(--leading-snug);
}
.notif-body {
font-size: var(--text-xs);
color: var(--c-text-secondary);
margin-top: var(--space-1);
line-height: var(--leading-relaxed);
}
.notif-time {
font-size: var(--text-xs);
color: var(--c-text-muted);
margin-top: var(--space-1);
}
.notif-del-btn {
flex-shrink: 0;
color: var(--c-text-muted);
opacity: 0;
transition: opacity var(--transition-fast);
}
.notif-item:hover .notif-del-btn {
opacity: 1;
}
`;
document.head.appendChild(style);
}
return { init };
})();

View file

@ -17,6 +17,9 @@ window.Page_routes = (() => {
let _sortBy = 'newest';
let _onlyMine = false;
// 'mine' | 'discover'
let _browseMode = 'mine';
// Ansichts-Modus: 'list' | 'map'
let _viewMode = 'list';
let _searchMap = null; // L.map Instanz der Suchkarte
@ -76,6 +79,10 @@ window.Page_routes = (() => {
_container.innerHTML = `
<div class="rk-layout">
<div class="rk-header">
<div class="rk-mode-toggle" id="rk-mode-toggle">
<button class="rk-mode-btn${_browseMode==='mine'?' active':''}" id="rk-mode-mine">${UI.icon('user')} Meine Routen</button>
<button class="rk-mode-btn${_browseMode==='discover'?' active':''}" id="rk-mode-discover">${UI.icon('compass')} Entdecken</button>
</div>
<div class="rk-search-row">
<input class="rk-search" id="rk-search" type="search"
placeholder="🔍 Route suchen…" autocomplete="off">
@ -83,7 +90,7 @@ window.Page_routes = (() => {
<button class="rk-view-btn${_viewMode==='list'?' active':''}" id="rk-view-list" title="Liste">${UI.icon('list')}</button>
<button class="rk-view-btn${_viewMode==='map'?' active':''}" id="rk-view-map" title="Karte">${UI.icon('map-trifold')}</button>
</div>
<label class="btn btn-secondary btn-sm rk-imp-btn" title="GPX / KML / TCX importieren">
<label class="btn btn-secondary btn-sm rk-imp-btn" id="rk-imp-wrap" title="GPX / KML / TCX importieren">
${UI.icon('download-simple')} Import
<input type="file" id="rk-import-input" accept=".gpx,.kml,.tcx" style="display:none">
</label>
@ -112,6 +119,9 @@ window.Page_routes = (() => {
<div class="rk-filter-group" id="rk-mine-group" style="display:none">
<button class="rk-chip" data-filter="mine" data-val="mine">🔒 Nur meine</button>
</div>
<div class="rk-filter-group" id="rk-nearby-group" style="display:none">
<button class="rk-chip" id="rk-nearby-btn" data-filter="nearby" data-val="">${UI.icon('map-pin')} In meiner Nähe</button>
</div>
</div>
</div>
<div class="rk-grid" id="rk-grid">
@ -145,8 +155,50 @@ window.Page_routes = (() => {
if (filter === 'terrain') _terrain = val;
if (filter === 'sort') _sortBy = val;
if (filter === 'mine') _onlyMine = chip.classList.contains('active') && val === 'mine';
if (filter === 'nearby') { _loadDataNearby(); return; } // async, calls _applyFilter itself
_applyFilter();
});
// Mode toggle
document.getElementById('rk-mode-mine').addEventListener('click', () => _setBrowseMode('mine'));
document.getElementById('rk-mode-discover').addEventListener('click', () => _setBrowseMode('discover'));
}
function _setBrowseMode(mode) {
_browseMode = mode;
document.getElementById('rk-mode-mine')?.classList.toggle('active', mode === 'mine');
document.getElementById('rk-mode-discover')?.classList.toggle('active', mode === 'discover');
const recBtn = document.getElementById('rk-rec-btn');
const impWrap = document.getElementById('rk-imp-wrap');
const mineGrp = document.getElementById('rk-mine-group');
const nearbyGrp = document.getElementById('rk-nearby-group');
if (mode === 'discover') {
if (recBtn) recBtn.style.display = 'none';
if (impWrap) impWrap.style.display = 'none';
if (mineGrp) mineGrp.style.display = 'none';
if (nearbyGrp && _userPos) nearbyGrp.style.display = '';
} else {
if (recBtn) recBtn.style.display = '';
if (impWrap) impWrap.style.display = '';
if (_appState.user && mineGrp) mineGrp.style.display = '';
if (nearbyGrp) nearbyGrp.style.display = 'none';
}
_onlyMine = false;
document.querySelectorAll('#rk-mine-group .rk-chip').forEach(c => c.classList.remove('active'));
_applyFilter();
}
async function _loadDataNearby() {
if (!_userPos) {
try { _userPos = await API.getLocation(); } catch { UI.toast.warning('Standort nicht verfügbar.'); return; }
}
try {
_data = await API.routes.listNearby(_userPos.lat, _userPos.lon, 10000);
_applyFilter();
} catch (err) {
UI.toast.error('Fehler beim Laden: ' + err.message);
}
}
// ----------------------------------------------------------
@ -328,10 +380,14 @@ window.Page_routes = (() => {
async function _loadData() {
try {
_data = await API.routes.list();
// "Meine Routen"-Filter nur zeigen wenn eingeloggt
if (_appState.user) {
// "Meine Routen"-Filter nur zeigen wenn eingeloggt und im Mine-Modus
if (_appState.user && _browseMode === 'mine') {
document.getElementById('rk-mine-group')?.style.setProperty('display', '');
}
// Standort-abhängiger Filter im Entdecken-Modus
if (_browseMode === 'discover' && _userPos) {
document.getElementById('rk-nearby-group')?.style.setProperty('display', '');
}
_applyFilter();
} catch (err) {
document.getElementById('rk-grid').innerHTML =
@ -346,13 +402,21 @@ window.Page_routes = (() => {
function _applyFilter() {
let list = [..._data];
// Browse-Modus-Filter
if (_browseMode === 'mine' && _appState.user) {
list = list.filter(r => r.user_id === _appState.user.id);
} else if (_browseMode === 'discover' && _appState.user) {
list = list.filter(r => r.user_id !== _appState.user.id);
}
if (_search) list = list.filter(r =>
(r.name||'').toLowerCase().includes(_search) ||
(r.beschreibung||'').toLowerCase().includes(_search) ||
(r.user_name||'').toLowerCase().includes(_search));
if (_difficulty) list = list.filter(r => r.schwierigkeit === _difficulty);
if (_terrain) list = list.filter(r => r.untergrund === _terrain);
if (_onlyMine && _appState.user)
if (_onlyMine && _appState.user && _browseMode === 'mine')
list = list.filter(r => r.user_id === _appState.user.id);
if (_sortBy === 'distance') list.sort((a,b) => (b.distanz_km||0) - (a.distanz_km||0));
@ -387,8 +451,15 @@ window.Page_routes = (() => {
document.querySelector('.rk-chip[data-val="newest"]')?.classList.add('active');
_applyFilter();
});
} else if (_browseMode === 'discover') {
// Entdecken: keine fremden Routen vorhanden
grid.innerHTML = `<div class="rk-empty">
<div class="rk-empty-icon">${UI.icon('compass')}</div>
<h3 class="rk-empty-title">Noch keine öffentlichen Routen</h3>
<p class="rk-empty-text">Andere Nutzer haben noch keine Routen geteilt. Sei der Erste!</p>
</div>`;
} else {
// Noch gar keine Routen
// 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>
@ -440,6 +511,7 @@ window.Page_routes = (() => {
// Karte HTML
// ----------------------------------------------------------
function _cardHTML(r) {
const isDiscover = _browseMode === 'discover';
const privBadge = !r.is_public ? `<span class="rk-badge rk-badge--private">${UI.icon('lock')} Privat</span>` : '';
const diffLabel = DIFFICULTY_LABEL[r.schwierigkeit] || '';
const terrain = TERRAIN_LABEL[r.untergrund] || '';
@ -453,10 +525,15 @@ window.Page_routes = (() => {
data-track='${JSON.stringify(r.preview_track||[])}'
style="width:100%;height:100%"></div>`;
const authorLine = isDiscover
? `<div class="rk-card-creator">${UI.icon('user')} ${_esc(r.user_name||'Anonym')}</div>`
: '';
return `
<div class="rk-card" data-id="${r.id}">
<div class="rk-card-preview">${previewContent}</div>
<div class="rk-card-body">
${authorLine}
<div class="rk-card-name">${_esc(r.name)}</div>
<div class="rk-card-stats">
${dist ? `<span>${UI.icon('map-trifold')} ${dist}</span>` : ''}
@ -465,7 +542,7 @@ window.Page_routes = (() => {
${paws ? `<span title="Hundetauglichkeit">${paws}</span>` : ''}
</div>
<div class="rk-card-tags">
${privBadge}
${isDiscover ? '' : privBadge}
${diffLabel ? `<span class="rk-badge rk-badge--${r.schwierigkeit}">${diffLabel}</span>` : ''}
${r.schatten ? `<span class="rk-badge">${UI.icon('tree')} Schatten</span>` : ''}
${r.leine_empfohlen ? `<span class="rk-badge">${UI.icon('link')} Leine</span>` : ''}
@ -473,7 +550,7 @@ window.Page_routes = (() => {
<div class="rk-card-footer">
<div class="rk-stars">${_starsHTML(r.id, r.bewertung||0, r.anz_bewertungen||0)}</div>
<div class="rk-card-actions">
<span class="rk-card-author">${_esc(r.user_name||'Anonym')}</span>
${isDiscover ? '' : `<span class="rk-card-author">${_esc(r.user_name||'Anonym')}</span>`}
<button class="rk-dl-btn" data-id="${r.id}">${UI.icon('download-simple')} GPX</button>
</div>
</div>

View file

@ -551,6 +551,11 @@ window.Page_settings = (() => {
UI.toast.success(`Willkommen zurück, ${_appState.user.name}!`);
// Push-Benachrichtigungen anbieten wenn noch nicht entschieden
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
_offerPushNotifications();
}
// Nach Login: Tagebuch oder Profil anlegen
if (_appState.activeDog) {
App.navigate('diary');
@ -585,13 +590,67 @@ window.Page_settings = (() => {
_appState.dogs = [];
_appState.activeDog = null;
UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}! 🐕`);
// Direkt zur Profil-Anlage
App.navigate('dog-profile');
UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}!`);
// Onboarding-Modal direkt zeigen (SPA — kein Reload)
App.showOnboarding();
});
});
}
// ----------------------------------------------------------
// PUSH-BENACHRICHTIGUNGEN ANBIETEN (nach Login)
// ----------------------------------------------------------
function _offerPushNotifications() {
// Kleiner Toast-Banner mit Ja-Button — nicht-invasiv
const toastEl = document.createElement('div');
toastEl.id = 'push-offer-banner';
toastEl.style.cssText = [
'position:fixed',
'bottom:calc(var(--nav-h, 64px) + var(--space-3))',
'left:50%',
'transform:translateX(-50%)',
'background:var(--c-surface)',
'border:1.5px solid var(--c-border)',
'border-radius:var(--radius-lg)',
'box-shadow:var(--shadow-lg)',
'padding:var(--space-3) var(--space-4)',
'display:flex',
'align-items:center',
'gap:var(--space-3)',
'font-size:var(--text-sm)',
'z-index:1100',
'max-width:340px',
'width:calc(100% - var(--space-8))',
].join(';');
toastEl.innerHTML = `
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)">
<use href="/icons/phosphor.svg#bell-ringing"></use>
</svg>
<span style="flex:1;line-height:1.4">Push-Benachrichtigungen aktivieren?</span>
<button id="push-offer-yes" class="btn btn-primary" style="font-size:var(--text-xs);padding:var(--space-1) var(--space-3);flex-shrink:0">Ja</button>
<button id="push-offer-no" class="btn btn-ghost btn-icon" aria-label="Schließen" style="flex-shrink:0">
<svg class="ph-icon" style="width:16px;height:16px" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
`;
document.body.appendChild(toastEl);
const remove = () => toastEl.remove();
document.getElementById('push-offer-yes')?.addEventListener('click', async () => {
remove();
try {
await API.subscribeToPush();
UI.toast.success('Push-Benachrichtigungen aktiviert.');
} catch {
UI.toast.warning('Push-Benachrichtigungen konnten nicht aktiviert werden.');
}
});
document.getElementById('push-offer-no')?.addEventListener('click', remove);
// Automatisch ausblenden nach 12 Sekunden
setTimeout(remove, 12000);
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------