feat: Edit Playlist modal — name, description, public toggle, cover upload

- 'Bearbeiten' button in playlist-detail header opens modal
- Subsonic updatePlaylist API: name, comment, public synced
- Cover upload via POST /api/playlist/{id}/image (Navidrome REST, Basic Auth)
- Cover delete via DELETE /api/playlist/{id}/image
- Graceful toast if Navidrome version doesn't support cover upload
- Mobile-responsive layout (stacks cover + fields on small screens)
- Bump SW cache to ns-v31

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-04-26 01:24:11 +02:00
1 parent dad2b0b730
commit 23d7c62045
4 files changed
+150 -2

No files matched your search

+78 -1
View File
@@ -422,15 +422,21 @@ async function quickPlayPlaylist(pl){
try{const r=await api('getPlaylist',`&id=${pl.id}`);const songs=r.playlist?.entry||[];playQueue(songs,0)}
catch(e){toast('Fehler: '+e.message)}
}
let _currentPlaylist=null;
async function loadPlaylistDetail(pl){
_currentPlaylist={...pl};
document.getElementById('playlist-detail-name').textContent=pl.name;
const cw=document.getElementById('playlist-cover');
const cv=coverUrl(pl.coverArt,300);
cw.innerHTML=cv?`<img src="${cv}" onerror="this.parentNode.innerHTML='<div class=\'no-art\'>🎶</div>'">`:'<div class="no-art">🎶</div>';
document.getElementById('btn-edit-playlist').onclick=()=>openEditPlModal(_currentPlaylist);
const sl=document.getElementById('playlist-songs');sl.innerHTML='<div class="spinner"></div>';
try{
const r=await api('getPlaylist',`&id=${pl.id}`);
const songs=r.playlist?.entry||[];
const full=r.playlist||{};
_currentPlaylist={...pl,...full,id:pl.id};
const songs=full.entry||[];
const totalMin=Math.round(songs.reduce((s,t)=>s+(t.duration||0),0)/60);
document.getElementById('playlist-detail-stats').textContent=`${songs.length} Titel · ${totalMin} Min.`;
document.getElementById('btn-play-playlist').onclick=()=>playQueue(songs,0);
@@ -442,6 +448,77 @@ async function loadPlaylistDetail(pl){
}catch(e){sl.innerHTML=`<p style="color:var(--danger)">${e.message}</p>`}
}
// ─── EDIT PLAYLIST ───
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');
}
function closeEditPlModal(){document.getElementById('edit-pl-overlay').classList.remove('open')}
async function saveEditPlaylist(){
const pl=_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}`);
_currentPlaylist={..._currentPlaylist,name,comment,public:pub};
document.getElementById('playlist-detail-name').textContent=name;
closeEditPlModal();
toast('Playlist aktualisiert');
}catch(e){toast('Fehler: '+e.message)}
finally{btn.disabled=false;btn.textContent='Speichern';}
}
function editPlUploadCover(){document.getElementById('edit-pl-file').click()}
async function editPlCoverSelected(input){
const file=input.files?.[0];if(!file||!_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 form=new FormData();form.append('image',file);
const auth='Basic '+btoa(`${cfg.user}:${cfg.pass}`);
const res=await fetch(`${cfg.url}/api/playlist/${_currentPlaylist.id}/image`,{method:'POST',headers:{Authorization:auth},body:form});
if(!res.ok)throw new Error(res.status);
toast('Cover aktualisiert');
// Refresh cover in detail view
setTimeout(()=>{
const cv=document.getElementById('playlist-cover');
const img=cv?.querySelector('img');if(img)img.src=img.src.split('?')[0]+'?t='+Date.now();
},500);
}catch{
toast('Cover-Upload nicht unterstützt (Navidrome-Version)');
document.getElementById('edit-pl-cover').innerHTML=
(coverUrl(_currentPlaylist.coverArt,300)
?`<img src="${coverUrl(_currentPlaylist.coverArt,300)}" 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>');
}
}
async function editPlRemoveCover(){
if(!_currentPlaylist)return;
try{
const auth='Basic '+btoa(`${cfg.user}:${cfg.pass}`);
const res=await fetch(`${cfg.url}/api/playlist/${_currentPlaylist.id}/image`,{method:'DELETE',headers:{Authorization:auth}});
if(!res.ok)throw new Error(res.status);
toast('Cover entfernt');
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>';
}catch{toast('Entfernen nicht unterstützt (Navidrome-Version)')}
}
// ─── RADIO MODE (endloser Shuffle aus der eigenen Bibliothek) ───
let radioMode=false;
function _applyRadioBtn(){
+41
View File
@@ -234,6 +234,7 @@
<div class="album-actions">
<button class="btn-play-all" id="btn-play-playlist"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Abspielen</button>
<button class="btn-outline" id="btn-shuffle-playlist"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>Shuffle</button>
<button class="btn-outline" id="btn-edit-playlist"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Bearbeiten</button>
</div>
</div>
</div>
@@ -491,6 +492,46 @@
</div>
</div>
<!-- EDIT PLAYLIST MODAL -->
<div class="edit-pl-overlay" id="edit-pl-overlay" onclick="if(event.target===this)closeEditPlModal()">
<div class="edit-pl-box">
<div class="edit-pl-header">
<span>Playlist bearbeiten</span>
<button class="edit-pl-close" onclick="closeEditPlModal()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="edit-pl-body">
<div class="edit-pl-cover-wrap">
<div class="edit-pl-cover" id="edit-pl-cover"><div class="no-art">🎶</div></div>
<div class="edit-pl-cover-actions">
<button class="edit-pl-cover-btn" onclick="document.getElementById('edit-pl-file').click()" title="Bild hochladen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</button>
<button class="edit-pl-cover-btn" onclick="editPlRemoveCover()" title="Bild entfernen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
</button>
</div>
<input type="file" id="edit-pl-file" accept="image/*" style="display:none" onchange="editPlCoverSelected(this)">
</div>
<div class="edit-pl-fields">
<label class="edit-pl-label">Name <span style="color:var(--accent)">*</span></label>
<input class="edit-pl-input" id="edit-pl-name" type="text" placeholder="Playlist-Name">
<label class="edit-pl-label">Beschreibung</label>
<textarea class="edit-pl-input edit-pl-textarea" id="edit-pl-desc" placeholder="Optionale Beschreibung…" rows="3"></textarea>
<div class="edit-pl-toggle-row">
<span>Öffentlich</span>
<div class="toggle" id="edit-pl-public-toggle" onclick="this.classList.toggle('on')"></div>
</div>
</div>
</div>
<div class="edit-pl-footer">
<button class="edit-pl-btn-cancel" onclick="closeEditPlModal()">Abbrechen</button>
<button class="edit-pl-btn-save" onclick="saveEditPlaylist()">Speichern</button>
</div>
</div>
</div>
<!-- SONG INFO MODAL -->
<div class="info-modal" id="info-modal">
<div class="inner">
+30
View File
@@ -338,6 +338,36 @@ nav{flex:1;overflow-y:auto;padding:4px 10px}
.pl-btns{display:flex;gap:8px}
.pl-btns button{flex:1;padding:10px;border-radius:8px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;border:none}
/* ── EDIT PLAYLIST MODAL ── */
.edit-pl-overlay{position:fixed;inset:0;background:rgba(0,0,0,.72);backdrop-filter:blur(5px);z-index:500;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .2s}
.edit-pl-overlay.open{opacity:1;pointer-events:all}
.edit-pl-box{width:min(580px,calc(100vw - 24px));background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px rgba(0,0,0,.8);overflow:hidden}
.edit-pl-header{display:flex;align-items:center;justify-content:space-between;padding:20px 24px 16px;font-size:18px;font-weight:700;border-bottom:1px solid var(--border)}
.edit-pl-close{background:none;border:none;color:var(--text2);cursor:pointer;padding:4px;display:flex;border-radius:6px;transition:color .15s}
.edit-pl-close:hover{color:var(--text)}
.edit-pl-body{display:flex;gap:24px;padding:24px;align-items:flex-start}
.edit-pl-cover-wrap{position:relative;flex-shrink:0;width:180px;height:180px;border-radius:12px;overflow:hidden;background:var(--surface2)}
.edit-pl-cover{width:100%;height:100%}
.edit-pl-cover img{width:100%;height:100%;object-fit:cover;display:block}
.edit-pl-cover-actions{position:absolute;bottom:8px;right:8px;display:flex;gap:6px;opacity:0;transition:opacity .2s}
.edit-pl-cover-wrap:hover .edit-pl-cover-actions{opacity:1}
.edit-pl-cover-actions{opacity:1}
.edit-pl-cover-btn{width:34px;height:34px;background:rgba(0,0,0,.65);border:none;border-radius:50%;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);transition:background .15s}
.edit-pl-cover-btn:hover{background:rgba(0,0,0,.85)}
.edit-pl-fields{flex:1;min-width:0;display:flex;flex-direction:column;gap:14px}
.edit-pl-label{font-size:12px;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:.05em;margin-bottom:-8px}
.edit-pl-input{width:100%;padding:10px 13px;background:var(--bg2);border:1px solid var(--border2);border-radius:9px;color:var(--text);font-family:var(--font);font-size:14px;outline:none;transition:border-color .2s;box-sizing:border-box}
.edit-pl-input:focus{border-color:var(--accent)}
.edit-pl-textarea{resize:vertical;min-height:72px;line-height:1.5}
.edit-pl-toggle-row{display:flex;align-items:center;justify-content:space-between;padding:4px 0}
.edit-pl-toggle-row span{font-size:14px;font-weight:500}
.edit-pl-footer{display:flex;justify-content:flex-end;gap:10px;padding:16px 24px;border-top:1px solid var(--border)}
.edit-pl-btn-cancel{padding:10px 20px;border-radius:9px;border:none;background:none;color:var(--text2);font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;transition:color .15s}
.edit-pl-btn-cancel:hover{color:var(--text)}
.edit-pl-btn-save{padding:10px 22px;border-radius:9px;border:none;background:var(--accent);color:#000;font-family:var(--font);font-size:14px;font-weight:700;cursor:pointer;transition:opacity .15s}
.edit-pl-btn-save:hover{opacity:.85}
@media(max-width:480px){.edit-pl-body{flex-direction:column;align-items:center}.edit-pl-cover-wrap{width:140px;height:140px}}
/* ── SONG INFO MODAL ── */
.info-modal{position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px);z-index:400;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .2s}
.info-modal.open{opacity:1;pointer-events:all}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v30';
const CACHE = 'ns-v31';
const PRECACHE = ['/', 'index.html', 'styles.css', 'app.js', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];