small tweeks
This commit is contained in:
+208
-29
@@ -93,6 +93,7 @@
|
||||
<th class="p-3 w-16">Artwork</th>
|
||||
<th class="p-3">Title</th>
|
||||
<th class="p-3">Artist</th>
|
||||
<th class="p-3">Album</th>
|
||||
<th class="p-3">Audio Filename</th>
|
||||
<th class="p-3 w-80">Search & Results</th>
|
||||
</tr>
|
||||
@@ -107,7 +108,8 @@
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-3">
|
||||
Artwork sources: iTunes Search API, MusicBrainz + Cover Art Archive — 100% free, no API keys.
|
||||
Hover any thumbnail to see its source; click it to inspect the full-size image. Exported images are
|
||||
Edit title, artist, or album in the grid to adjust artwork searches. These edits are temporary and do not update RadioDJ metadata.
|
||||
The last option in each row lets you upload custom artwork or use a direct image URL. Hover any thumbnail to see its source; click it to inspect the full-size image. Exported images are
|
||||
renamed to the audio file’s base name (e.g. <span class="font-mono">01 Track.mp3 → 01 Track.jpg</span>).
|
||||
Nothing is exported until you tick the track’s checkbox to confirm its artwork.
|
||||
</p>
|
||||
@@ -132,6 +134,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================= Custom artwork modal ======================= -->
|
||||
<div id="customArtModal" class="modal hidden">
|
||||
<div class="modal-card max-w-xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">Use custom artwork</h2>
|
||||
<button type="button" class="btn-ghost" data-close="customArtModal">✕</button>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400 mb-4">Choose an image file or provide a direct image URL. The image will be previewed for confirmation before export.</p>
|
||||
<div class="space-y-4">
|
||||
<label class="block text-sm text-slate-300">
|
||||
Upload an image (PNG, JPEG, or WebP; up to 15 MB)
|
||||
<input id="customArtFile" type="file" accept="image/png,image/jpeg,image/webp" class="inp mt-2">
|
||||
</label>
|
||||
<div>
|
||||
<label for="customArtUrl" class="block text-sm text-slate-300">Or enter a direct image URL</label>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<input id="customArtUrl" type="url" class="inp" placeholder="https://example.com/album-art.jpg">
|
||||
<button id="btnUseArtUrl" type="button" class="btn-primary shrink-0">Use URL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= Profiles modal ========================= -->
|
||||
<div id="profilesModal" class="modal hidden">
|
||||
<div class="modal-card max-w-3xl">
|
||||
@@ -194,9 +220,11 @@ const state = {
|
||||
profiles: [],
|
||||
activeProfileId: null,
|
||||
tracks: [],
|
||||
metadataEdits: new Map(), // trackId -> edited artist/title/album for the current load
|
||||
selections: new Map(), // trackId -> { imageUrl, thumb, sourceLabel }
|
||||
candidates: new Map(), // trackId -> [{ full, thumb, source, sourceLabel, artist, album }] — persists across filter re-renders
|
||||
artModal: { trackId: null, full: '' },
|
||||
customArtTrackId: null,
|
||||
authMode: 'login',
|
||||
};
|
||||
let currentPage = 1;
|
||||
@@ -409,29 +437,40 @@ function thumbHtml(r, selectedUrl) {
|
||||
title="Source: ${esc(sourceLabel(r))} — click to preview full size" loading="lazy" alt="artwork option">`;
|
||||
}
|
||||
|
||||
function candidateOptionsHtml(trackId, results, selectedUrl) {
|
||||
const candidates = results.map(r => thumbHtml(r, selectedUrl)).join('');
|
||||
return `${candidates}
|
||||
<button type="button" class="upload-art-btn w-14 h-14 rounded border-2 border-dashed border-slate-500 hover:border-indigo-400 hover:text-indigo-300 text-slate-300 flex items-center justify-center"
|
||||
title="Upload an image or use a direct image URL" aria-label="Add custom artwork" data-track-id="${trackId}">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 16V4m0 0L7 9m5-5 5 5M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/>
|
||||
</svg>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function trackRowHtml(t) {
|
||||
const sel = state.selections.get(t.id);
|
||||
const candidates = state.candidates.get(t.id) || [];
|
||||
return `<tr data-id="${t.id}" class="border-b border-slate-700/50 hover:bg-slate-700/20">
|
||||
<td class="p-3 text-center"><input type="checkbox" class="row-check accent-indigo-500 w-4 h-4" ${sel ? 'checked' : ''} title="Confirm this artwork for export"></td>
|
||||
<td class="p-3"><div class="preview-slot w-14 h-14 rounded overflow-hidden bg-slate-700/60 flex items-center justify-center text-slate-500 text-xs">
|
||||
${sel
|
||||
? `<img src="${esc(sel.thumb)}" data-full="${esc(sel.imageUrl)}" data-source-label="${esc(sel.sourceLabel || '')}" class="preview-img w-full h-full object-cover cursor-zoom-in" title="Source: ${esc(sel.sourceLabel || 'Unknown')} — click to preview full size" alt="artwork">`
|
||||
: t.placeholder
|
||||
? `<span class="text-amber-400 text-[10px] leading-tight text-center px-0.5" title="RadioDJ placeholder: ${esc(t.image)}">placeholder art</span>`
|
||||
: '<span class="text-slate-500">missing</span>'}</div></td>
|
||||
<td class="p-3 font-medium text-slate-100">${esc(t.title)}</td>
|
||||
<td class="p-3 text-slate-300">${esc(t.artist)}</td>
|
||||
: `<img src="/Album-Art.png" class="w-full h-full object-contain" alt="Default album artwork">`}</div></td>
|
||||
<td class="p-3"><input class="metadata-input inp !w-full !min-w-36 !py-1 font-medium text-slate-100" data-field="title" aria-label="Title" value="${esc(t.title)}"></td>
|
||||
<td class="p-3"><input class="metadata-input inp !w-full !min-w-36 !py-1 text-slate-300" data-field="artist" aria-label="Artist" value="${esc(t.artist)}"></td>
|
||||
<td class="p-3"><input class="metadata-input inp !w-full !min-w-36 !py-1 text-slate-300" data-field="album" aria-label="Album" value="${esc(t.album)}"></td>
|
||||
<td class="p-3 font-mono text-xs text-slate-400 break-all">${esc(t.audioBase)}</td>
|
||||
<td class="p-3">
|
||||
<button type="button" class="search-btn btn-mini" title="Search iTunes / Cover Art Archive for artwork — review results, then tick the checkbox to confirm">Search artwork</button>
|
||||
<div class="thumbs flex flex-wrap gap-1.5 mt-2">${(state.candidates.get(t.id) || []).map(r => thumbHtml(r, sel?.imageUrl)).join('')}</div>
|
||||
<div class="thumbs flex flex-wrap gap-1.5 mt-2">${candidateOptionsHtml(t.id, candidates, sel?.imageUrl)}</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderTracks() {
|
||||
const f = $('filterInput').value.trim().toLowerCase();
|
||||
const rows = state.tracks.filter(t => !f || [t.title, t.artist, t.audioBase].join(' ').toLowerCase().includes(f));
|
||||
const rows = state.tracks.filter(t => !f || [t.title, t.artist, t.album, t.audioBase].join(' ').toLowerCase().includes(f));
|
||||
$('tracksBody').innerHTML = rows.map(trackRowHtml).join('');
|
||||
const empty = $('emptyState');
|
||||
empty.style.display = rows.length ? 'none' : '';
|
||||
@@ -442,6 +481,21 @@ function renderTracks() {
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function clearArtworkChoices() {
|
||||
const objectUrls = new Set();
|
||||
for (const candidates of state.candidates.values()) {
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.file && candidate.full.startsWith('blob:')) objectUrls.add(candidate.full);
|
||||
}
|
||||
}
|
||||
for (const selection of state.selections.values()) {
|
||||
if (selection.file && selection.imageUrl.startsWith('blob:')) objectUrls.add(selection.imageUrl);
|
||||
}
|
||||
for (const url of objectUrls) URL.revokeObjectURL(url);
|
||||
state.selections.clear();
|
||||
state.candidates.clear();
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const checked = document.querySelectorAll('#tracksBody .row-check:checked').length;
|
||||
$('trackStats').textContent = state.tracks.length
|
||||
@@ -485,18 +539,21 @@ function renderPagination(totalPages) {
|
||||
<button type="button" id="btnNext" class="btn-mini" ${currentPage >= totalPages ? 'disabled' : ''}>Next →</button>`;
|
||||
}
|
||||
|
||||
async function loadTracks() {
|
||||
async function loadTracks({ resetMetadataEdits = false } = {}) {
|
||||
if (!state.activeProfileId) return toast('Add and select a station profile first (Profiles button).', 'error');
|
||||
const btn = $('btnLoadTracks');
|
||||
btn.disabled = true; btn.textContent = 'Loading…';
|
||||
try {
|
||||
const data = await api(`/api/tracks?profileId=${state.activeProfileId}&page=${currentPage}&limit=${currentLimit}`);
|
||||
state.tracks = data.tracks;
|
||||
if (resetMetadataEdits) state.metadataEdits.clear();
|
||||
state.tracks = data.tracks.map(track => ({
|
||||
...track,
|
||||
...(state.metadataEdits.get(track.id) || {}),
|
||||
}));
|
||||
totalTracks = data.totalTracks || 0;
|
||||
currentPage = data.page || 1;
|
||||
renderPagination(data.totalPages || 1);
|
||||
state.selections.clear();
|
||||
state.candidates.clear();
|
||||
clearArtworkChoices();
|
||||
renderTracks();
|
||||
toast(`${data.tracks.length} track(s) missing artwork loaded.`, 'success');
|
||||
} catch (err) {
|
||||
@@ -512,13 +569,10 @@ async function searchForRow(tr, track) {
|
||||
btn.disabled = true; btn.textContent = 'Searching…';
|
||||
try {
|
||||
const data = await api('/api/artwork/search', { method: 'POST', body: { artist: track.artist, title: track.title, album: track.album, source: $('apiSourceSelect').value } });
|
||||
if (!data.results.length) {
|
||||
box.innerHTML = '<span class="text-xs text-slate-500">No artwork found.</span>';
|
||||
return;
|
||||
}
|
||||
state.candidates.set(track.id, data.results);
|
||||
const sel = state.selections.get(track.id);
|
||||
box.innerHTML = data.results.map(r => thumbHtml(r, sel?.imageUrl)).join('');
|
||||
box.innerHTML = candidateOptionsHtml(track.id, data.results, sel?.imageUrl);
|
||||
if (!data.results.length) box.insertAdjacentHTML('afterbegin', '<span class="text-xs text-slate-500">No artwork found.</span>');
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
@@ -527,7 +581,13 @@ async function searchForRow(tr, track) {
|
||||
}
|
||||
|
||||
function setTrackSelection(tr, track, img) {
|
||||
state.selections.set(track.id, { imageUrl: img.dataset.full, thumb: img.dataset.thumb, sourceLabel: img.dataset.sourceLabel || '' });
|
||||
const candidate = (state.candidates.get(track.id) || []).find(item => item.full === img.dataset.full);
|
||||
state.selections.set(track.id, {
|
||||
imageUrl: img.dataset.full,
|
||||
thumb: img.dataset.thumb,
|
||||
sourceLabel: img.dataset.sourceLabel || '',
|
||||
file: candidate?.file || null,
|
||||
});
|
||||
tr.querySelectorAll('.thumb').forEach(t => { t.classList.remove('border-indigo-500'); t.classList.add('border-transparent'); });
|
||||
tr.querySelectorAll(`.thumb[data-full="${CSS.escape(img.dataset.full)}"]`).forEach(t => { t.classList.remove('border-transparent'); t.classList.add('border-indigo-500'); });
|
||||
tr.querySelector('.preview-slot').innerHTML = `<img src="${esc(img.dataset.thumb)}" data-full="${esc(img.dataset.full)}" data-source-label="${esc(img.dataset.sourceLabel || '')}" class="preview-img w-full h-full object-cover cursor-zoom-in" title="Source: ${esc(img.dataset.sourceLabel || 'Unknown')} — click to preview full size" alt="selected artwork">`;
|
||||
@@ -544,7 +604,13 @@ function selectThumb(tr, track, img) {
|
||||
function openArtModal(trackId, imgData) {
|
||||
const t = state.tracks.find(x => x.id === trackId);
|
||||
if (!t) return;
|
||||
state.artModal = { trackId, full: imgData.full, thumb: imgData.thumb || imgData.full, sourceLabel: imgData.sourceLabel || '' };
|
||||
state.artModal = {
|
||||
trackId,
|
||||
full: imgData.full,
|
||||
thumb: imgData.thumb || imgData.full,
|
||||
sourceLabel: imgData.sourceLabel || '',
|
||||
file: imgData.file || null,
|
||||
};
|
||||
$('artImg').src = imgData.thumb || imgData.full; // thumb first, then upgrade to hi-res
|
||||
$('artImg').src = imgData.full;
|
||||
$('artTitle').textContent = t.title || '(untitled)';
|
||||
@@ -554,24 +620,90 @@ function openArtModal(trackId, imgData) {
|
||||
openModal('artModal');
|
||||
}
|
||||
|
||||
function confirmArtSelection() {
|
||||
const { trackId, full, thumb, sourceLabel } = state.artModal;
|
||||
async function confirmArtSelection() {
|
||||
const { trackId, full, thumb, sourceLabel, file } = state.artModal;
|
||||
const tr = document.querySelector(`tr[data-id="${trackId}"]`);
|
||||
const track = state.tracks.find(x => x.id === trackId);
|
||||
if (tr && track) {
|
||||
setTrackSelection(tr, track, { dataset: { full, thumb, sourceLabel } });
|
||||
const candidate = (state.candidates.get(trackId) || []).find(item => item.full === full);
|
||||
const selectedFile = candidate?.file || file || null;
|
||||
if (selectedFile) {
|
||||
state.selections.set(track.id, { imageUrl: full, thumb, sourceLabel, file: selectedFile });
|
||||
tr.querySelector('.preview-slot').innerHTML = `<img src="${esc(thumb)}" data-full="${esc(full)}" data-source-label="${esc(sourceLabel)}" class="preview-img w-full h-full object-cover cursor-zoom-in" title="Source: ${esc(sourceLabel || 'Unknown')} — click to preview full size" alt="selected artwork">`;
|
||||
updateStats();
|
||||
} else {
|
||||
setTrackSelection(tr, track, { dataset: { full, thumb, sourceLabel } });
|
||||
}
|
||||
tr.querySelector('.row-check').checked = true;
|
||||
updateStats();
|
||||
toast('Artwork confirmed for export.', 'success');
|
||||
let source = 'unknown';
|
||||
if (selectedFile) source = 'manual_upload';
|
||||
else if (candidate?.source && ['itunes', 'deezer', 'musicbrainz'].includes(candidate.source)) source = candidate.source;
|
||||
else if (sourceLabel === 'Custom image URL') source = 'url_upload';
|
||||
try {
|
||||
await api('/api/artwork/selection', { method: 'POST', body: { trackId: track.id, source } });
|
||||
} catch (err) {
|
||||
toast(`Artwork was selected, but selection logging failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
closeModal('artModal');
|
||||
}
|
||||
|
||||
function openCustomArtModal(trackId) {
|
||||
state.customArtTrackId = trackId;
|
||||
$('customArtFile').value = '';
|
||||
$('customArtUrl').value = '';
|
||||
openModal('customArtModal');
|
||||
}
|
||||
|
||||
function addCustomArtworkCandidate({ full, thumb = full, sourceLabel, file = null }) {
|
||||
const trackId = state.customArtTrackId;
|
||||
const track = state.tracks.find(item => item.id === trackId);
|
||||
const tr = document.querySelector(`tr[data-id="${trackId}"]`);
|
||||
if (!track || !tr) return toast('Track is no longer visible. Reload it and try again.', 'error');
|
||||
const candidate = { full, thumb, sourceLabel, artist: track.artist, album: track.album, file };
|
||||
const candidates = state.candidates.get(trackId) || [];
|
||||
candidates.push(candidate);
|
||||
state.candidates.set(trackId, candidates);
|
||||
tr.querySelector('.thumbs').innerHTML = candidateOptionsHtml(trackId, candidates, state.selections.get(trackId)?.imageUrl);
|
||||
closeModal('customArtModal');
|
||||
openArtModal(trackId, candidate);
|
||||
}
|
||||
|
||||
function useCustomArtFile() {
|
||||
const file = $('customArtFile').files[0];
|
||||
if (!file) return toast('Choose an image file first.', 'error');
|
||||
if (file.size > 15 * 1024 * 1024) return toast('Image exceeds 15 MB limit.', 'error');
|
||||
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
||||
return toast('Choose a PNG, JPEG, or WebP image.', 'error');
|
||||
}
|
||||
addCustomArtworkCandidate({
|
||||
full: URL.createObjectURL(file),
|
||||
sourceLabel: `Uploaded image: ${file.name}`,
|
||||
file,
|
||||
});
|
||||
}
|
||||
|
||||
function useCustomArtUrl() {
|
||||
const value = $('customArtUrl').value.trim();
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return toast('Enter a valid direct image URL.', 'error');
|
||||
}
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
||||
return toast('Image URL must be a public HTTP or HTTPS URL.', 'error');
|
||||
}
|
||||
addCustomArtworkCandidate({ full: url.href, sourceLabel: 'Custom image URL' });
|
||||
}
|
||||
|
||||
function fillRowFromBest(tr, track, best) {
|
||||
// Suggest the top hit as a visual preview WITHOUT checking the confirmation box —
|
||||
// the user must review it and tick the box manually before export.
|
||||
state.candidates.set(track.id, [best]);
|
||||
tr.querySelector('.thumbs').innerHTML = thumbHtml(best, null);
|
||||
tr.querySelector('.thumbs').innerHTML = candidateOptionsHtml(track.id, [best], null);
|
||||
tr.querySelector('.preview-slot').innerHTML = `<img src="${esc(best.thumb)}" data-full="${esc(best.full)}" data-source-label="${esc(sourceLabel(best))}" class="preview-img w-full h-full object-cover cursor-zoom-in" title="Source: ${esc(sourceLabel(best))} — suggested, click to preview">`;
|
||||
}
|
||||
|
||||
@@ -603,23 +735,36 @@ async function autoSearch() {
|
||||
// ----------------------------- export -------------------------------------
|
||||
async function exportSelected() {
|
||||
const items = [];
|
||||
const uploadedFiles = [];
|
||||
document.querySelectorAll('#tracksBody .row-check:checked').forEach(cb => {
|
||||
const id = Number(cb.closest('tr').dataset.id);
|
||||
const t = state.tracks.find(x => x.id === id);
|
||||
const sel = state.selections.get(id);
|
||||
if (t && sel) items.push({ id: t.id, path: t.path, audioBase: t.audioBase, imageUrl: sel.imageUrl });
|
||||
if (t && sel) {
|
||||
items.push({
|
||||
id: t.id,
|
||||
path: t.path,
|
||||
audioBase: t.audioBase,
|
||||
imageUrl: sel.file ? '' : sel.imageUrl,
|
||||
upload: Boolean(sel.file),
|
||||
});
|
||||
if (sel.file) uploadedFiles.push({ id: t.id, file: sel.file });
|
||||
}
|
||||
});
|
||||
if (!items.length) return toast('Nothing to export — select artwork for at least one track.', 'error');
|
||||
if (uploadedFiles.length > 20) return toast('You can export up to 20 uploaded images at a time.', 'error');
|
||||
|
||||
const updateDb = $('chkUpdateDb').checked;
|
||||
const btn = $('btnExport');
|
||||
btn.disabled = true; btn.textContent = 'Working…';
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify({ profileId: state.activeProfileId, updateDb, items }));
|
||||
for (const { id, file } of uploadedFiles) formData.append(`upload-${id}`, file, file.name);
|
||||
const res = await fetch('/api/export', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profileId: state.activeProfileId, updateDb, items }),
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = `Export failed (${res.status})`;
|
||||
@@ -634,7 +779,14 @@ async function exportSelected() {
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(a.href);
|
||||
toast(`Exported ${items.length} artwork file(s)${updateDb ? ' and updated the RadioDJ database' : ''}. See export-report.json in the ZIP.`, 'success');
|
||||
const downloaded = Number(res.headers.get('X-Export-Downloaded') || 0);
|
||||
const errors = Number(res.headers.get('X-Export-Errors') || 0);
|
||||
toast(
|
||||
errors
|
||||
? `Exported ${downloaded} artwork file(s); ${errors} item(s) had errors. See export-report.json in the ZIP.`
|
||||
: `Exported ${downloaded} artwork file(s)${updateDb ? ' and updated the RadioDJ database' : ''}.`,
|
||||
errors ? 'error' : 'success',
|
||||
);
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
@@ -698,6 +850,12 @@ function bindEvents() {
|
||||
$('stationSelect').addEventListener('change', e => {
|
||||
state.activeProfileId = Number(e.target.value) || null;
|
||||
currentPage = 1;
|
||||
state.metadataEdits.clear();
|
||||
state.tracks = [];
|
||||
clearArtworkChoices();
|
||||
totalTracks = 0;
|
||||
renderPagination(0);
|
||||
renderTracks();
|
||||
renderProfilesList();
|
||||
});
|
||||
|
||||
@@ -715,7 +873,7 @@ function bindEvents() {
|
||||
$('pfCancel').addEventListener('click', resetProfileForm);
|
||||
$('profilesList').addEventListener('click', handleProfileAction);
|
||||
|
||||
$('btnLoadTracks').addEventListener('click', loadTracks);
|
||||
$('btnLoadTracks').addEventListener('click', () => loadTracks({ resetMetadataEdits: true }));
|
||||
$('btnAutoSearch').addEventListener('click', autoSearch);
|
||||
$('filterInput').addEventListener('input', renderTracks);
|
||||
$('btnExport').addEventListener('click', exportSelected);
|
||||
@@ -734,12 +892,16 @@ function bindEvents() {
|
||||
if (!tr) return;
|
||||
const track = state.tracks.find(t => t.id === Number(tr.dataset.id));
|
||||
if (!track) return;
|
||||
if (e.target.classList.contains('search-btn')) await searchForRow(tr, track);
|
||||
if (e.target.closest('.upload-art-btn')) openCustomArtModal(track.id);
|
||||
else if (e.target.classList.contains('search-btn')) await searchForRow(tr, track);
|
||||
else if (e.target.classList.contains('thumb') || e.target.classList.contains('preview-img')) {
|
||||
const candidate = (state.candidates.get(track.id) || []).find(item => item.full === (e.target.dataset.full || e.target.src));
|
||||
const selected = state.selections.get(track.id);
|
||||
openArtModal(track.id, {
|
||||
full: e.target.dataset.full || e.target.src,
|
||||
thumb: e.target.dataset.thumb || e.target.src,
|
||||
sourceLabel: e.target.dataset.sourceLabel || '',
|
||||
file: candidate?.file || (selected?.imageUrl === (e.target.dataset.full || e.target.src) ? selected.file : null),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -754,6 +916,21 @@ function bindEvents() {
|
||||
updateStats();
|
||||
});
|
||||
|
||||
$('tracksBody').addEventListener('input', e => {
|
||||
if (!e.target.classList.contains('metadata-input')) return;
|
||||
const tr = e.target.closest('tr[data-id]');
|
||||
const track = state.tracks.find(t => t.id === Number(tr.dataset.id));
|
||||
const field = e.target.dataset.field;
|
||||
if (track && ['title', 'artist', 'album'].includes(field)) {
|
||||
track[field] = e.target.value;
|
||||
state.metadataEdits.set(track.id, {
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$('adminUsers').addEventListener('change', handleRoleChange);
|
||||
$('btnClone').addEventListener('click', cloneProfiles);
|
||||
|
||||
@@ -778,6 +955,8 @@ function bindEvents() {
|
||||
$('artClose').addEventListener('click', () => closeModal('artModal'));
|
||||
$('artImg').addEventListener('click', () => { if (state.artModal.full) window.open(state.artModal.full, '_blank', 'noopener'); });
|
||||
$('artConfirm').addEventListener('click', confirmArtSelection);
|
||||
$('customArtFile').addEventListener('change', useCustomArtFile);
|
||||
$('btnUseArtUrl').addEventListener('click', useCustomArtUrl);
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && !$('artModal').classList.contains('hidden')) closeModal('artModal');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user