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
+158
-3
No files matched your search
+18
@@ -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>
|
||||
@@ -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,11 +462,25 @@ 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';
|
||||
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
@@ -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}
|
||||
|
||||
|
||||
Reference in new issue
Block a user