banyaro/backend/static/js/pages/notifications.js
rene eef787cc72 Fix: Benachrichtigungen öffnen direkt das Ziel-Item
- forum_reply → öffnet direkt den Forum-Thread (openThread exposed)
- walk_invite → öffnet direkt das Gassi-Treffen (openDetail exposed)
- poison_alert → öffnet direkt den Giftköder-Eintrag (openDetail exposed)
- chat_message → öffnet direkt den Chat-Thread (war schon vorhanden)
- _execNav nutzt nav.data statt nav.data?.field für konsistentes Parsing
2026-04-19 10:22:13 +02:00

383 lines
12 KiB
JavaScript

/* 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';
}
}
/** Parst n.data sicher und flacht verschachteltes {data:{…}} auf */
function _parseData(raw) {
if (!raw) return {};
let obj = (typeof raw === 'object') ? raw : (() => {
try { return JSON.parse(raw); } catch (_) { return {}; }
})();
// push.py speichert payload-Felder direkt: {data: {conversation_id:…}, tag:…}
// Das innere 'data' nach oben holen damit d.conversation_id etc. direkt verfügbar sind
if (obj.data && typeof obj.data === 'object') {
obj = { ...obj, ...obj.data };
}
return obj;
}
/**
* Gibt { page, label, go } zurück.
* go() führt die Navigation inklusive kontextspezifischer Aktion aus.
*/
function _navTarget(n) {
const d = _parseData(n.data);
switch (n.type) {
case 'chat_message':
return {
page: 'chat', label: 'Chat',
go: d.conversation_id
? () => App.callModule('chat', '_openThread', d.conversation_id)
: () => App.navigate('chat'),
};
case 'forum_reply':
case 'forum_mention':
return {
page: 'forum', label: 'Forum',
go: d.id
? () => App.callModule('forum', 'openThread', d.id)
: () => App.navigate('forum'),
};
case 'walk_invite':
return {
page: 'walks', label: 'Gassi-Treffen',
go: d.walk_id
? () => App.callModule('walks', 'openDetail', d.walk_id)
: () => App.navigate('walks'),
};
case 'poison_alert':
return {
page: 'poison', label: 'Giftköder-Alarm',
go: d.id
? () => App.callModule('poison', 'openDetail', { id: d.id })
: () => App.navigate('poison'),
};
case 'friend_request':
return {
page: 'friends', label: 'Freunde',
go: () => App.navigate('friends'),
};
case 'health_reminder':
return {
page: 'health', label: 'Gesundheit',
go: () => App.navigate('health'),
};
case 'milestone':
return {
page: 'diary', label: 'Tagebuch',
go: () => App.navigate('diary'),
};
default: {
const page = d.page || '';
return {
page,
label: page,
go: page ? () => App.navigate(page) : null,
};
}
}
}
/** 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(' ');
const nav = _navTarget(n);
// Nur serialisierbare Felder speichern (go ist eine Funktion, nicht serialisierbar)
const navData = JSON.stringify({ page: nav.page, label: nav.label, type: n.type,
data: _parseData(n.data) });
return `
<button class="${cls}" data-id="${n.id}" data-nav="${UI.escape(navData)}"
type="button" style="width:100%;text-align:left;border:none;font:inherit;background:none">
<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>
<span class="notif-del-btn" data-del="${n.id}" title="Löschen"
aria-label="Benachrichtigung löschen" role="button">
${UI.icon('x')}
</span>
</button>`;
}
/** Führt die kontextspezifische Navigation aus */
function _execNav(nav) {
const d = nav.data || {};
switch (nav.type) {
case 'chat_message':
d.conversation_id
? App.callModule('chat', '_openThread', d.conversation_id)
: App.navigate('chat');
break;
case 'forum_reply':
case 'forum_mention':
d.id
? App.callModule('forum', 'openThread', d.id)
: App.navigate('forum');
break;
case 'walk_invite':
d.walk_id
? App.callModule('walks', 'openDetail', d.walk_id)
: App.navigate('walks');
break;
case 'poison_alert':
d.id
? App.callModule('poison', 'openDetail', { id: d.id })
: App.navigate('poison');
break;
case 'friend_request': App.navigate('friends'); break;
case 'health_reminder':App.navigate('health'); break;
case 'milestone': App.navigate('diary'); break;
default:
if (nav.page) App.navigate(nav.page);
}
}
// ----------------------------------------------------------
// 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?.error?.('Fehler beim Markieren.');
}
});
}
// ----------------------------------------------------------
// 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 nav = JSON.parse(el.dataset.nav || '{}');
// Sofortiges visuelles Feedback
el.classList.remove('notif-unread');
el.style.opacity = '0.6';
// Als gelesen markieren (fire & forget)
API.notifications.read(id).catch(() => {});
if (nav.page) {
if (nav.label) UI.toast?.info?.(`Öffne ${nav.label}`);
setTimeout(() => _execNav(nav), 150);
} else {
setTimeout(() => { el.style.opacity = ''; }, 800);
}
});
});
// Löschen-Buttons
list.querySelectorAll('.notif-del-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = parseInt(btn.dataset.del, 10);
const item = btn.closest('button.notif-item') || btn.closest('.notif-item');
// Sofortiges visuelles Feedback
if (item) {
item.style.transition = 'opacity 0.2s, transform 0.2s';
item.style.opacity = '0';
item.style.transform = 'translateX(20px)';
}
try {
await API.notifications.delete(id);
item?.remove();
if (!list.querySelector('.notif-item')) {
list.innerHTML = `
<div class="empty-state">
${UI.icon('bell-slash')}
<p>Keine Benachrichtigungen</p>
</div>`;
}
} catch (_) {
// Rückgängig machen falls Fehler
if (item) {
item.style.opacity = '';
item.style.transform = '';
}
UI.toast?.error?.('Löschen fehlgeschlagen.');
}
});
});
}
// ----------------------------------------------------------
// 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.45;
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
}
.notif-item:hover .notif-del-btn,
.notif-del-btn:focus-visible {
opacity: 1;
color: var(--c-danger, #e53e3e);
}
.notif-del-btn:active {
background: var(--c-danger-subtle, rgba(229,62,62,.12));
}
`;
document.head.appendChild(style);
}
return { init };
})();