feat: add notification bell with persistent download log
- Bell icon in sidebar and mobile header with unread badge - On download complete/error: saves notification to localStorage (survives reload) - Playlist/album downloads: parses job log to list which tracks failed - Panel toggles open with animation, click-outside closes it - Each notification dismissable with X; "clear all" button - Notifications capped at 100 entries in localStorage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
fef8578a27
commit
d5a26b764d
4 files changed
+159
-4
No files matched your search
@@ -29,6 +29,7 @@ import {
|
||||
selectDlType, executeDownload, executeSpotifyDownload, openEditPlModal, closeEditPlModal,
|
||||
saveEditPlaylist, editPlUploadCover, editPlCoverSelected, editPlRemoveCover,
|
||||
toggleMobileSearch, closeMobileSearch, switchSetupTab,
|
||||
toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell,
|
||||
} from './ui.js';
|
||||
|
||||
// ─── CONNECT ───
|
||||
@@ -80,6 +81,7 @@ async function startApp() {
|
||||
navigate('home');
|
||||
}
|
||||
loadHomeData();
|
||||
initNotifBell();
|
||||
_restoreLastPlayed();
|
||||
if(localStorage.getItem('ns_radio')==='1') { S.radioMode=true; _applyRadioBtn(); }
|
||||
}
|
||||
@@ -235,6 +237,7 @@ Object.assign(window, {
|
||||
openEditPlModal, closeEditPlModal, saveEditPlaylist,
|
||||
editPlUploadCover, editPlCoverSelected, editPlRemoveCover,
|
||||
toggleMobileSearch, closeMobileSearch, switchSetupTab,
|
||||
toggleNotifPanel, deleteNotif, clearNotifs,
|
||||
// App
|
||||
doConnect, disconnect,
|
||||
});
|
||||
@@ -339,6 +339,95 @@ async function _createNdPlaylist(tracks, name) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NOTIFICATIONS ───
|
||||
const _NOTIF_KEY = 'ns_notifications';
|
||||
|
||||
function _loadNotifs() {
|
||||
try { return JSON.parse(localStorage.getItem(_NOTIF_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
function _saveNotifs(n) { localStorage.setItem(_NOTIF_KEY, JSON.stringify(n.slice(0, 100))); }
|
||||
|
||||
function _addNotif(notif) {
|
||||
const list = _loadNotifs();
|
||||
list.unshift(notif);
|
||||
_saveNotifs(list);
|
||||
_renderNotifBell();
|
||||
}
|
||||
|
||||
export function deleteNotif(id) {
|
||||
_saveNotifs(_loadNotifs().filter(n => n.id !== id));
|
||||
_renderNotifBell();
|
||||
_renderNotifList();
|
||||
}
|
||||
|
||||
export function clearNotifs() {
|
||||
_saveNotifs([]);
|
||||
_renderNotifBell();
|
||||
_renderNotifList();
|
||||
}
|
||||
|
||||
export function toggleNotifPanel() {
|
||||
const panel = document.getElementById('notif-panel');
|
||||
const overlay = document.getElementById('notif-overlay');
|
||||
if (!panel) return;
|
||||
const isOpen = panel.classList.contains('open');
|
||||
if (isOpen) {
|
||||
panel.classList.remove('open');
|
||||
overlay?.classList.remove('active');
|
||||
} else {
|
||||
_saveNotifs(_loadNotifs().map(n => ({...n, read: true})));
|
||||
_renderNotifList();
|
||||
_renderNotifBell();
|
||||
panel.classList.add('open');
|
||||
overlay?.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
export function initNotifBell() { _renderNotifBell(); }
|
||||
|
||||
function _renderNotifBell() {
|
||||
const unread = _loadNotifs().filter(n => !n.read).length;
|
||||
document.querySelectorAll('.notif-badge').forEach(b => {
|
||||
b.textContent = unread > 9 ? '9+' : unread;
|
||||
b.style.display = unread > 0 ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _renderNotifList() {
|
||||
const list = document.getElementById('notif-list');
|
||||
if (!list) return;
|
||||
const notifs = _loadNotifs();
|
||||
if (!notifs.length) {
|
||||
list.innerHTML = '<div class="notif-empty">Keine Benachrichtigungen</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = notifs.map(n => {
|
||||
const d = new Date(n.timestamp);
|
||||
const time = d.toLocaleDateString('de-DE', {day:'2-digit', month:'2-digit'}) + ' ' + d.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'});
|
||||
const icon = n.status === 'done' ? '✅' : '❌';
|
||||
const failedHtml = n.failed?.length
|
||||
? `<details class="notif-failed"><summary>${n.failed.length} Track${n.failed.length > 1 ? 's' : ''} fehlgeschlagen</summary><ul>${n.failed.map(f => `<li>${esc(f)}</li>`).join('')}</ul></details>`
|
||||
: '';
|
||||
return `<div class="notif-card${n.read ? '' : ' unread'}">
|
||||
<div class="notif-card-top">
|
||||
<span class="notif-icon">${icon}</span>
|
||||
<div class="notif-info">
|
||||
<div class="notif-title">${esc(n.title)}</div>
|
||||
<div class="notif-sub">${n.artist ? esc(n.artist) + ' · ' : ''}${time}</div>
|
||||
</div>
|
||||
<button class="notif-x" onclick="deleteNotif('${esc(n.id)}')">✕</button>
|
||||
</div>
|
||||
${n.message ? `<div class="notif-msg">${esc(n.message)}</div>` : ''}
|
||||
${failedHtml}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _parseFailedTracks(log) {
|
||||
const re = /Fehler bei "([^"]+)"/;
|
||||
return (log || []).map(line => re.exec(line)?.[1]).filter(Boolean);
|
||||
}
|
||||
|
||||
// ─── JOBS ───
|
||||
function addJob(id, title, artist) {
|
||||
S.jobs[id] = {title, artist, progress:0, status:'pending'};
|
||||
@@ -373,10 +462,24 @@ async function pollJob(jobId) {
|
||||
const res = await fetch(`${S.cfg.backendUrl}/api/status/${jobId}`);
|
||||
const d = await res.json();
|
||||
updateJob(jobId, d.status, d.message, d.progress);
|
||||
if(d.status!=='done' && d.status!=='error') setTimeout(() => pollJob(jobId), 1500);
|
||||
else if(d.status==='done') {
|
||||
const title = S.jobs[jobId]?.title || 'Track';
|
||||
toast(d.skipped ? `⏭ "${title}" bereits auf dem Server` : `✅ ${title} gespeichert!`);
|
||||
if(d.status !== 'done' && d.status !== 'error') {
|
||||
setTimeout(() => pollJob(jobId), 1500);
|
||||
} else {
|
||||
const job = S.jobs[jobId];
|
||||
const title = job?.title || 'Download';
|
||||
_addNotif({
|
||||
id: jobId,
|
||||
title,
|
||||
artist: job?.artist || '',
|
||||
status: d.status,
|
||||
message: d.message || (d.status === 'done' ? 'Fertig!' : 'Fehler'),
|
||||
failed: _parseFailedTracks(d.log),
|
||||
timestamp: Date.now(),
|
||||
read: false,
|
||||
});
|
||||
if (d.status === 'done') {
|
||||
toast(d.skipped ? `⏭ "${title}" bereits auf dem Server` : `✅ ${title} gespeichert!`);
|
||||
}
|
||||
}
|
||||
} catch { updateJob(jobId, 'error', 'Status-Abfrage fehlgeschlagen'); }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user