Files
NovaSonic/src/ui/modals.js
T
marcandClaude Opus 5 9afd73ae7f feat(web): Playlists und Favoriten als Liste in der Seitenleiste
Statt zweier Knoepfe, die erst eine Uebersichtsseite oeffnen, steht unter den
Navigationsrubriken der Abschnitt "Deine Musik": angepinnt Favoriten und "Neu von
deinen Kuenstlern", darunter die Playlists mit Cover und Titelzahl.

Links und nicht rechts, weil rechts belegt ist: dort faehrt die Warteschlange als
290-px-Panel ein und am Rand sitzt der Rollbalken aus 3.1.

Der Listenteil rollt eigenstaendig (nav ist jetzt eine Flex-Spalte), auf niedrigen
Fenstern rollt als Rueckfall die ganze Leiste. Dieselbe gecachte Abfrage wie die
Uebersichtsseite; nach Anlegen/Umbenennen/Loeschen wird der Cache verworfen und neu
gezeichnet, ein Coverwechsel tauscht nur die betroffene Zeile (gleiche Adresse pl-<id>,
sonst kaeme dasselbe Bild aus dem Browser-Cache).

Die Kachel-Uebersicht bleibt per "Alle" erreichbar, die untere Leiste auf dem Handy
bleibt unveraendert.

Ausserdem: in 3.2 uebersehene Mischstelle nachgezogen — der Shuffle-Knopf der
Playlist-Seite benutzte weiter sort(() => Math.random()-.5). Mein Suchmuster hatte nur
die Schreibweise mit 0.5 erfasst, nicht die mit .5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 19:40:56 +02:00

250 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, coverUrl, ndAuth, invalidateApiCache } from '../api.js';
// Circular — safe (only used inside function bodies)
import { playQueue, dlSongLocal, nativeInsertAt, renderQueue } from '../player/player.js';
import { navigate, loadPlaylists, loadPlaylistDetail, renderSidebarPlaylists, bustSidebarCover } from '../views/views.js';
// ─── CONTEXT MENU ───
export function showCtxMenu(e, song, list, idx) {
const menu = document.getElementById('ctx-menu');
menu.innerHTML = '';
const items = [
{ icon: '▶', label: 'Abspielen', fn: () => playQueue(list, idx) },
{ icon: '+', label: 'Als Nächstes einfügen', fn: () => {
const insertIdx = S.qIdx + 1;
S.queue.splice(insertIdx, 0, {...song});
nativeInsertAt(song, insertIdx);
renderQueue(); toast('In Warteschlange eingefügt');
}},
{ icon: '⬇', label: 'Am Ende der Queue', fn: () => {
S.queue.push({...song});
nativeInsertAt(song, -1);
renderQueue(); toast('Zur Queue hinzugefügt');
}},
{ sep: true },
{ icon: '🎶', label: 'Zu Playlist hinzufügen →', submenu: true, fn: () => openPlModal([song]) },
{ icon: '⭐', label: song._starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten', fn: () => {
api(song._starred ? 'unstar' : 'star', `&id=${song.id}`)
.then(() => { song._starred = !song._starred; toast(song._starred ? '⭐ Favorit hinzugefügt' : 'Favorit entfernt'); })
.catch(err => toast('Fehler: ' + err.message));
}},
{ sep: true },
{ icon: '💿', label: 'Zum Album', fn: () => song.albumId && navigate('album-detail', { id: song.albumId, name: song.album||'', artist: song.artist||'', artistId: song.artistId||'', coverArt: song.coverArt||'' }) },
{ icon: '🔗', label: 'Teilen', fn: () => import('./share.js').then(m => m.shareSong(song)) },
{ icon: 'ℹ', label: 'Song-Info', fn: () => import('./metadata.js').then(m => m.showSongInfo(song)) },
{ icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) },
];
items.forEach(item => {
if(item.sep) { const d=document.createElement('div'); d.className='ctx-separator'; menu.appendChild(d); return; }
const el = document.createElement('div'); el.className = 'ctx-item';
el.innerHTML = `<span style="font-size:14px;width:18px;text-align:center">${item.icon}</span><span>${item.label}</span>`;
el.onclick = () => { hideCtxMenu(); item.fn(); };
menu.appendChild(el);
});
const vw=window.innerWidth, vh=window.innerHeight;
let x=e.clientX, y=e.clientY;
menu.style.left='-9999px'; menu.style.top='-9999px'; menu.style.display='block';
const w=menu.offsetWidth, h=menu.offsetHeight;
if(x+w>vw-8) x=vw-w-8;
if(y+h>vh-8) y=vh-h-8;
menu.style.left=x+'px'; menu.style.top=y+'px';
}
export function hideCtxMenu() {
const m = document.getElementById('ctx-menu');
if (!m) return;
m.style.display='none'; m.innerHTML='';
}
document.addEventListener('click', e => {
const m = document.getElementById('ctx-menu');
if (m && !m.contains(e.target)) hideCtxMenu();
});
document.addEventListener('scroll', () => {
const m = document.getElementById('ctx-menu');
if (m && m.style.display !== 'none') hideCtxMenu();
}, { passive: true, capture: true });
document.addEventListener('touchmove', () => {
const m = document.getElementById('ctx-menu');
if (m && m.style.display !== 'none') hideCtxMenu();
}, { passive: true, capture: true });
document.addEventListener('keydown', e => { if(e.key==='Escape') hideCtxMenu(); });
// ─── PLAYLIST MODAL (Add to / Create) ───
export async function openPlModal(songs) {
S.plModalSongs = songs;
document.getElementById('pl-modal-title').textContent =
songs.length===1 ? `"${songs[0].title}" zu Playlist` : `${songs.length} Songs zu Playlist`;
document.getElementById('pl-new-name').value = '';
const list = document.getElementById('pl-list');
list.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px">Lädt…</div>';
document.getElementById('pl-modal').classList.add('open');
try {
const r = await api('getPlaylists');
S.cachedPlaylists = r.playlists?.playlist || [];
list.innerHTML = '';
if(!S.cachedPlaylists.length) {
list.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px">Noch keine Playlists vorhanden</div>';
return;
}
S.cachedPlaylists.forEach(pl => {
const row = document.createElement('div'); row.className = 'pl-row';
const cv = pl.coverArt ? coverUrl(pl.coverArt, 96) : null;
const inner = cv
? `<img src="${cv}" alt="" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">`
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/></svg>';
row.innerHTML = `<span style="width:34px;height:34px;border-radius:6px;flex-shrink:0;background:var(--surface2);display:flex;align-items:center;justify-content:center;overflow:hidden;color:var(--text3)">${inner}</span>
<span>${esc(pl.name)}</span><span style="margin-left:auto;font-size:11px;color:var(--text3)">${pl.songCount} Titel</span>`;
row.onclick = () => addSongsToPlaylist(pl.id, pl.name);
list.appendChild(row);
});
} catch(e) { list.innerHTML = `<div style="color:var(--danger);font-size:12px;padding:8px">${e.message}</div>`; }
}
export function closePlModal() {
document.getElementById('pl-modal').classList.remove('open');
S.plModalSongs = [];
}
export async function addSongsToPlaylist(plId, plName) {
const songs = [...S.plModalSongs];
closePlModal();
try {
const ids = songs.map(s => `&songIdToAdd=${s.id}`).join('');
await api('updatePlaylist', `&playlistId=${plId}${ids}`);
toast(`✅ ${songs.length===1 ? songs[0].title : songs.length+' Songs'} → ${plName}`);
} catch(e) { toast('Fehler: ' + e.message); }
}
export async function createAndAddPlaylist() {
const name = document.getElementById('pl-new-name').value.trim();
if(!name) { document.getElementById('pl-new-name').focus(); return; }
closePlModal();
try {
const ids = S.plModalSongs.map(s => `&songId=${s.id}`).join('');
await api('createPlaylist', `&name=${encodeURIComponent(name)}${ids}`);
toast(`✅ Playlist "${name}" erstellt`);
invalidateApiCache('getPlaylists'); // sonst zeigt die Seitenleiste die neue Liste 3 Min. nicht
renderSidebarPlaylists();
if(document.getElementById('page-playlists').classList.contains('active')) loadPlaylists();
} catch(e) { toast('Fehler: ' + e.message); }
}
export function createPlaylistPrompt() {
document.getElementById('pl-new-name').value = '';
S.plModalSongs = [];
document.getElementById('pl-modal-title').textContent = 'Neue Playlist erstellen';
document.getElementById('pl-list').innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px 0">Füge Songs später per Rechtsklick hinzu.</div>';
document.getElementById('pl-modal').classList.add('open');
}
document.addEventListener('click', e => {
const m = document.getElementById('pl-modal');
if(m.classList.contains('open') && e.target===m) closePlModal();
});
// ─── CONFIRM DIALOG ───
let _confirmResolve = null;
export function _confirmOk() { document.getElementById('confirm-overlay').classList.remove('open'); _confirmResolve?.(true); _confirmResolve = null; }
export function _confirmCancel() { document.getElementById('confirm-overlay').classList.remove('open'); _confirmResolve?.(false); _confirmResolve = null; }
export function showConfirm(title, msg, okLabel = 'Bestätigen') {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-msg').textContent = msg;
document.getElementById('confirm-ok-btn').textContent = okLabel;
document.getElementById('confirm-overlay').classList.add('open');
return new Promise(resolve => { _confirmResolve = resolve; });
}
// ─── EDIT PLAYLIST MODAL ───
export function openEditPlModal(pl) {
const overlay = document.getElementById('edit-pl-overlay');
document.getElementById('edit-pl-name').value = pl.name || '';
document.getElementById('edit-pl-desc').value = pl.comment || '';
const tog = document.getElementById('edit-pl-public-toggle');
tog.classList.toggle('on', !!pl.public);
const cv = coverUrl(pl.coverArt, 300);
document.getElementById('edit-pl-cover').innerHTML = cv
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
overlay.classList.add('open');
}
export function closeEditPlModal() { document.getElementById('edit-pl-overlay').classList.remove('open'); }
export async function saveEditPlaylist() {
const pl = S._currentPlaylist; if(!pl) return;
const name = document.getElementById('edit-pl-name').value.trim();
if(!name) { toast('Name darf nicht leer sein'); return; }
const comment = document.getElementById('edit-pl-desc').value.trim();
const pub = document.getElementById('edit-pl-public-toggle').classList.contains('on');
const btn = document.querySelector('.edit-pl-btn-save');
btn.disabled=true; btn.textContent='Speichern…';
try {
await api('updatePlaylist', `&playlistId=${pl.id}&name=${encodeURIComponent(name)}&comment=${encodeURIComponent(comment)}&public=${pub}`);
S._currentPlaylist = {...S._currentPlaylist, name, comment, public:pub};
document.getElementById('playlist-detail-name').textContent = name;
const descEl = document.getElementById('playlist-detail-desc');
if(descEl) { descEl.textContent=comment; descEl.style.display=comment?'':'none'; }
closeEditPlModal();
toast('Playlist aktualisiert');
invalidateApiCache('getPlaylists'); // Name/Sichtbarkeit stehen auch in der Seitenleiste
renderSidebarPlaylists();
} catch(e) { toast('Fehler: '+e.message); }
finally { btn.disabled=false; btn.textContent='Speichern'; }
}
export function editPlUploadCover() { document.getElementById('edit-pl-file').click(); }
export async function editPlCoverSelected(input) {
const file = input.files?.[0]; if(!file || !S._currentPlaylist) return;
input.value = '';
const preview = URL.createObjectURL(file);
document.getElementById('edit-pl-cover').innerHTML = `<img src="${preview}" style="width:100%;height:100%;object-fit:cover">`;
try {
const token = await ndAuth();
const form = new FormData(); form.append('image', file, file.name);
const res = await fetch(`${S.cfg.url}/api/playlist/${S._currentPlaylist.id}/image`, {
method: 'POST',
headers: {'X-Nd-Authorization': 'Bearer '+token},
body: form,
});
if(!res.ok) throw new Error(res.status);
toast('Cover aktualisiert');
setTimeout(() => {
const bust = coverUrl(S._currentPlaylist.coverArt, 300) + '&_cb=' + Date.now();
const detailImg = document.getElementById('playlist-cover');
if(detailImg) detailImg.innerHTML = `<img src="${bust}" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎶</div>'">`;
document.getElementById('edit-pl-cover').innerHTML = `<img src="${bust}" style="width:100%;height:100%;object-fit:cover">`;
bustSidebarCover(S._currentPlaylist.id, S._currentPlaylist.coverArt);
}, 800);
} catch(e) {
S._ndToken = null;
toast('Cover-Upload fehlgeschlagen: '+e.message);
const cv = coverUrl(S._currentPlaylist.coverArt, 300);
document.getElementById('edit-pl-cover').innerHTML = cv
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
}
}
export async function editPlRemoveCover() {
if(!S._currentPlaylist) return;
try {
const token = await ndAuth();
const res = await fetch(`${S.cfg.url}/api/playlist/${S._currentPlaylist.id}/image`, {
method: 'DELETE', headers: {'X-Nd-Authorization': 'Bearer '+token},
});
if(!res.ok) throw new Error(res.status);
toast('Cover entfernt');
S._currentPlaylist.coverArt = null;
document.getElementById('edit-pl-cover').innerHTML = '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
document.getElementById('playlist-cover').innerHTML = '<div class="no-art">🎶</div>';
bustSidebarCover(S._currentPlaylist?.id, null);
} catch(e) { S._ndToken = null; toast('Fehler: '+e.message); }
}
export async function deletePlaylist() {
const pl = S._currentPlaylist;
if (!pl) return;
const ok = await showConfirm('Playlist löschen', `"${pl.name}" wird unwiderruflich gelöscht.`, 'Löschen');
if (!ok) return;
try {
await api('deletePlaylist', `&id=${pl.id}`);
closeEditPlModal();
toast(`🗑 "${pl.name}" gelöscht`);
invalidateApiCache('getPlaylists'); // drop the 3-min cache so the list reloads without the deleted playlist
renderSidebarPlaylists();
navigate('playlists');
} catch(e) { toast('Fehler beim Löschen: ' + e.message); }
}