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:
HoneyandClaude Sonnet 4.6 committed 2026-04-28 08:54:20 +02:00
1 parent fef8578a27
commit d5a26b764d
4 files changed
+159 -4

No files matched your search

+18
View File
@@ -48,6 +48,10 @@
<span>Nova<em>Sonic</em></span>
</div>
<div class="header-btns">
<button class="hbtn notif-bell-btn" onclick="toggleNotifPanel()" title="Benachrichtigungen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notif-badge" style="display:none">0</span>
</button>
<button class="hbtn" onclick="openSettings()" title="Einstellungen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M14.24 9.76a4 4 0 0 1 0 4.48M4.93 4.93a10 10 0 0 0 0 14.14M9.76 9.76a4 4 0 0 0 0 4.48"/></svg>
</button>
@@ -121,6 +125,10 @@
<span style="font-size:15px;font-weight:700">Nova<em style="color:var(--accent);font-style:normal">Sonic</em></span>
</div>
<div style="display:flex;align-items:center;gap:4px">
<button class="hbtn notif-bell-btn" onclick="toggleNotifPanel()" title="Benachrichtigungen" style="padding:8px;color:var(--text2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notif-badge" style="display:none">0</span>
</button>
<button class="hbtn" id="mobile-search-btn" onclick="toggleMobileSearch()" title="Suche" style="padding:8px;color:var(--text2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</button>
@@ -739,5 +747,15 @@
</div>
</nav>
<!-- NOTIFICATION PANEL -->
<div id="notif-overlay" class="notif-overlay" onclick="toggleNotifPanel()"></div>
<div id="notif-panel" class="notif-panel">
<div class="notif-panel-hdr">
<span class="notif-panel-title">Benachrichtigungen</span>
<button class="notif-clear-btn" onclick="clearNotifs()">Alle löschen</button>
</div>
<div id="notif-list" class="notif-list"></div>
</div>
</body>
</html>
+3
View File
@@ -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,
});
+107 -4
View File
@@ -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'); }
}
+31
View File
@@ -79,6 +79,37 @@ nav{flex:1;overflow-y:auto;padding:4px 10px}
.page-title em{color:var(--accent);font-style:normal}
.page-title .blue{color:var(--blue)}
/* ── NOTIFICATION BELL ── */
.notif-bell-btn{position:relative}
.notif-badge{position:absolute;top:1px;right:1px;min-width:15px;height:15px;background:var(--accent);color:#000;border-radius:8px;font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 3px;pointer-events:none;line-height:1}
/* ── NOTIFICATION PANEL ── */
.notif-overlay{display:none;position:fixed;inset:0;z-index:599}
.notif-overlay.active{display:block}
.notif-panel{position:fixed;top:12px;right:12px;width:320px;max-height:72vh;background:var(--surface);border:1px solid var(--border2);border-radius:14px;z-index:600;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,.5);transform:translateY(-8px) scale(.97);opacity:0;pointer-events:none;transition:opacity .18s,transform .18s}
.notif-panel.open{transform:translateY(0) scale(1);opacity:1;pointer-events:all}
.notif-panel-hdr{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
.notif-panel-title{font-size:14px;font-weight:700}
.notif-clear-btn{font-size:11px;color:var(--text3);background:none;border:none;cursor:pointer;padding:2px 6px;border-radius:4px;transition:color .15s}
.notif-clear-btn:hover{color:var(--text)}
.notif-list{overflow-y:auto;flex:1;padding:8px;display:flex;flex-direction:column;gap:6px}
.notif-empty{font-size:13px;color:var(--text3);text-align:center;padding:28px 0}
.notif-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:10px 12px;font-size:13px}
.notif-card.unread{border-color:var(--accent-dim)}
.notif-card-top{display:flex;align-items:flex-start;gap:8px}
.notif-icon{font-size:15px;flex-shrink:0;margin-top:1px}
.notif-info{flex:1;min-width:0}
.notif-title{font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.notif-sub{font-size:11px;color:var(--text3);margin-top:1px}
.notif-x{background:none;border:none;cursor:pointer;color:var(--text3);font-size:13px;padding:0;line-height:1;flex-shrink:0;transition:color .15s}
.notif-x:hover{color:var(--text)}
.notif-msg{font-size:12px;color:var(--text2);margin-top:6px}
.notif-failed{margin-top:6px}
.notif-failed summary{font-size:11px;color:var(--accent);cursor:pointer;user-select:none}
.notif-failed ul{margin:4px 0 0 14px;padding:0;font-size:11px;color:var(--text3)}
.notif-failed li{margin-bottom:2px}
@media(max-width:600px){.notif-panel{right:8px;left:8px;width:auto;top:60px}}
/* ── HOME GREETING ── */
.home-greeting{font-size:22px;font-weight:700;color:var(--text);padding:18px 0 6px;letter-spacing:-.3px}