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:
parent
5927d384bf
commit
92620c2c52
14 changed files with 1035 additions and 46 deletions
|
|
@ -1827,6 +1827,42 @@ textarea.form-control {
|
|||
gap: var(--space-2);
|
||||
}
|
||||
.rk-card-author { font-size: var(--text-xs); color: var(--c-text-muted); }
|
||||
.rk-card-creator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--c-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
/* Mode-Toggle: Meine Routen / Entdecken */
|
||||
.rk-mode-toggle {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: var(--space-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.rk-mode-btn {
|
||||
flex: 1;
|
||||
padding: 6px 16px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text-secondary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rk-mode-btn.active {
|
||||
background: var(--c-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.rk-mode-btn:first-child { border-right: 1px solid var(--c-border); }
|
||||
.rk-dl-btn {
|
||||
font-size: var(--text-xs);
|
||||
padding: 4px 8px;
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@
|
|||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=87">
|
||||
<link rel="stylesheet" href="/css/components.css?v=87">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=93">
|
||||
<link rel="stylesheet" href="/css/components.css?v=93">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -68,6 +68,10 @@
|
|||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg> Nachrichten
|
||||
<span class="sidebar-item-badge" id="chat-badge" style="display:none">0</span>
|
||||
</div>
|
||||
<div class="sidebar-item" data-page="notifications">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bell"></use></svg> Benachrichtigungen
|
||||
<span class="sidebar-item-badge" id="notif-badge" style="display:none">0</span>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-section-label">Community</span>
|
||||
<div class="sidebar-item" data-page="poison">
|
||||
|
|
@ -251,6 +255,10 @@
|
|||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-notifications">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- MOBILE BOTTOM NAVIGATION -->
|
||||
|
|
@ -289,9 +297,9 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=92"></script>
|
||||
<script src="/js/ui.js?v=92"></script>
|
||||
<script src="/js/app.js?v=92"></script>
|
||||
<script src="/js/api.js?v=93"></script>
|
||||
<script src="/js/ui.js?v=93"></script>
|
||||
<script src="/js/app.js?v=93"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
})();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
229
backend/static/js/pages/notifications.js
Normal file
229
backend/static/js/pages/notifications.js
Normal 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 };
|
||||
})();
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v150';
|
||||
const CACHE_VERSION = 'by-v152';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue