981 lines
46 KiB
HTML
981 lines
46 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" class="h-full">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>RDJ Art Manager</title>
|
||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎵</text></svg>">
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<style type="text/tailwindcss">
|
||
.btn-primary { @apply bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold px-3 py-2 rounded-lg transition whitespace-nowrap; }
|
||
.btn-ghost { @apply bg-slate-700/60 hover:bg-slate-600/60 disabled:opacity-50 text-slate-200 text-sm font-medium px-3 py-2 rounded-lg transition whitespace-nowrap; }
|
||
.btn-mini { @apply bg-slate-700 hover:bg-slate-600 disabled:opacity-50 text-slate-100 text-xs font-medium px-2 py-1 rounded transition; }
|
||
.btn-danger { @apply bg-rose-600/80 hover:bg-rose-500 text-white text-xs font-medium px-2 py-1 rounded transition; }
|
||
.inp { @apply bg-slate-900/70 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 w-full; }
|
||
.modal { @apply fixed inset-0 z-50 items-start justify-center bg-black/60 p-4 overflow-y-auto; }
|
||
.modal-card { @apply bg-slate-800 border border-slate-700 rounded-xl p-6 w-full mt-10 shadow-2xl; }
|
||
.lbl { @apply block text-xs font-medium text-slate-400 mb-1; }
|
||
</style>
|
||
</head>
|
||
<body class="bg-slate-900 text-slate-200 min-h-full">
|
||
|
||
<!-- ============================== Header ============================== -->
|
||
<header class="sticky top-0 z-40 bg-slate-900/90 backdrop-blur border-b border-slate-700/60">
|
||
<div class="max-w-7xl mx-auto px-4 h-14 flex items-center gap-3">
|
||
<div class="flex items-center gap-2 font-bold text-lg text-white select-none">
|
||
<span class="text-2xl">🎵</span> RDJ Art Manager
|
||
</div>
|
||
<div id="navArea" class="ml-auto hidden items-center gap-2">
|
||
<label class="text-xs text-slate-400 hidden sm:block">Station</label>
|
||
<select id="stationSelect" class="inp !w-auto !py-1.5 max-w-[240px]"></select>
|
||
<label class="text-xs text-slate-400 hidden sm:block">Source</label>
|
||
<select id="apiSourceSelect" title="Primary artwork source" class="bg-slate-900 border border-slate-600 rounded px-3 py-1.5 text-sm text-slate-200">
|
||
<option value="auto">Auto (iTunes → Deezer → MusicBrainz)</option>
|
||
<option value="itunes">iTunes API Only</option>
|
||
<option value="deezer">Deezer API Only</option>
|
||
<option value="musicbrainz">MusicBrainz / CoverArtArchive Only</option>
|
||
</select>
|
||
<button id="btnStations" class="btn-ghost">Profiles</button>
|
||
<button id="btnAdmin" class="btn-ghost hidden">Admin</button>
|
||
<span id="userChip" class="text-xs px-2 py-1.5 rounded-lg bg-slate-700/80 text-slate-200"></span>
|
||
<button id="btnLogout" class="btn-ghost">Log out</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ============================== Auth ============================== -->
|
||
<section id="authView" class="max-w-md mx-auto mt-16 px-4 hidden">
|
||
<div class="bg-slate-800/70 border border-slate-700 rounded-xl p-6 shadow-xl">
|
||
<h1 class="text-xl font-bold text-white mb-1">Welcome</h1>
|
||
<p class="text-sm text-slate-400 mb-5">Sign in to manage RadioDJ album artwork.</p>
|
||
<div class="flex mb-6 rounded-lg overflow-hidden border border-slate-600">
|
||
<button id="tabLogin" type="button" class="flex-1 py-2 text-sm font-semibold bg-indigo-600 text-white transition">Sign in</button>
|
||
<button id="tabRegister" type="button" class="flex-1 py-2 text-sm font-semibold text-slate-300 hover:bg-slate-700/50 transition">Register</button>
|
||
</div>
|
||
<form id="authForm" class="space-y-4">
|
||
<div>
|
||
<label class="lbl" for="authUsername">Username</label>
|
||
<input id="authUsername" class="inp" autocomplete="username" required minlength="3" maxlength="32">
|
||
</div>
|
||
<div>
|
||
<label class="lbl" for="authPassword">Password</label>
|
||
<input id="authPassword" type="password" class="inp" autocomplete="current-password" required minlength="8">
|
||
</div>
|
||
<p id="authHint" class="text-xs text-slate-500">The first registered account becomes the administrator.</p>
|
||
<button id="authSubmit" type="submit" class="btn-primary w-full">Sign in</button>
|
||
<p id="authError" class="text-sm text-rose-400 hidden"></p>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ============================== App ============================== -->
|
||
<main id="appView" class="max-w-7xl mx-auto px-4 py-6 hidden">
|
||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||
<button id="btnLoadTracks" class="btn-primary">Load tracks missing artwork</button>
|
||
<button id="btnAutoSearch" class="btn-ghost" disabled>Fetch Art for Current Page</button>
|
||
<input id="filterInput" placeholder="Filter title / artist / file…" class="inp !w-56">
|
||
<span id="trackStats" class="text-xs text-slate-400"></span>
|
||
<div class="ml-auto flex items-center gap-3">
|
||
<label class="flex items-center gap-1.5 text-sm text-slate-300 select-none" title="When enabled, the export also runs UPDATE songs SET image = '<new file name>' on the selected station database.">
|
||
<input type="checkbox" id="chkUpdateDb" class="accent-indigo-500 w-4 h-4" checked>
|
||
Update RadioDJ DB
|
||
</label>
|
||
<button id="btnExport" class="btn-primary" disabled title="Only tracks whose checkbox is ticked are exported">Download ZIP & Update Database</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bg-slate-800/60 border border-slate-700 rounded-xl overflow-hidden">
|
||
<div class="overflow-auto max-h-[72vh]">
|
||
<table class="w-full text-sm">
|
||
<thead class="sticky top-0 bg-slate-800 z-10 shadow">
|
||
<tr class="text-left text-xs uppercase tracking-wide text-slate-400">
|
||
<th class="p-3 w-10 text-center"><input type="checkbox" id="checkAll" class="accent-indigo-500 w-4 h-4"></th>
|
||
<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>
|
||
</thead>
|
||
<tbody id="tracksBody"></tbody>
|
||
</table>
|
||
<div id="paginationBar" class="hidden flex flex-wrap items-center gap-2 px-3 py-2 border-t border-slate-700/60 text-sm"></div>
|
||
<div id="emptyState" class="p-10 text-center text-slate-500 text-sm">
|
||
Select a station profile, then click “Load tracks missing artwork”.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-slate-500 mt-3">
|
||
Artwork sources: iTunes Search API, MusicBrainz + Cover Art Archive — 100% free, no API keys.
|
||
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>
|
||
</main>
|
||
|
||
<!-- ====================== Artwork preview overlay ===================== -->
|
||
<div id="artModal" class="modal hidden">
|
||
<div class="relative flex flex-col items-center justify-center min-h-[calc(100vh-2rem)] w-full">
|
||
<button id="artClose" class="absolute top-2 right-2 btn-ghost !text-lg" title="Close (Esc)">✕</button>
|
||
<img id="artImg" src="" alt="High resolution artwork"
|
||
class="max-h-[70vh] max-w-[90vw] rounded-xl shadow-2xl object-contain bg-slate-800 cursor-pointer"
|
||
title="Click to view the original image in a new tab">
|
||
<div class="mt-4 text-center">
|
||
<div id="artTitle" class="text-lg font-semibold text-white"></div>
|
||
<div id="artArtist" class="text-sm text-slate-400"></div>
|
||
<div id="artSource" class="text-xs text-slate-500 mt-1"></div>
|
||
</div>
|
||
<div class="mt-4 flex items-center gap-3">
|
||
<a id="artOriginal" href="#" target="_blank" rel="noopener noreferrer" class="btn-ghost">View Original Image ↗</a>
|
||
<button id="artConfirm" class="btn-primary">✓ Confirm & Select This Art</button>
|
||
</div>
|
||
</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">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-lg font-semibold text-white">Station Profiles — RadioDJ MySQL</h2>
|
||
<button class="btn-ghost" data-close="profilesModal">✕</button>
|
||
</div>
|
||
<div id="profilesList" class="space-y-2 mb-6"></div>
|
||
<h3 id="profileFormTitle" class="text-sm font-semibold text-slate-300 mb-2">Add profile</h3>
|
||
<form id="profileForm" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<input type="hidden" id="pfId">
|
||
<input id="pfName" class="inp" placeholder="Profile name (e.g. Main FM)" required maxlength="64">
|
||
<div class="flex gap-2">
|
||
<input id="pfHost" class="inp flex-1" placeholder="Host (e.g. 192.168.1.10)" required>
|
||
<input id="pfPort" class="inp !w-24" placeholder="3306" type="number" min="1" max="65535">
|
||
</div>
|
||
<input id="pfDb" class="inp" placeholder="Database name (e.g. radiodj)" required>
|
||
<input id="pfUser" class="inp" placeholder="DB user" required>
|
||
<input id="pfPass" class="inp sm:col-span-2" placeholder="DB password" type="password" autocomplete="new-password">
|
||
<div class="sm:col-span-2 flex items-center gap-2">
|
||
<button type="submit" class="btn-primary">Save profile</button>
|
||
<button type="button" id="pfCancel" class="btn-ghost hidden">Cancel edit</button>
|
||
<span id="pfMsg" class="text-xs"></span>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ========================== Admin modal =========================== -->
|
||
<div id="adminModal" class="modal hidden">
|
||
<div class="modal-card max-w-2xl">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-lg font-semibold text-white">Administration</h2>
|
||
<button class="btn-ghost" data-close="adminModal">✕</button>
|
||
</div>
|
||
<h3 class="text-sm font-semibold text-slate-300 mb-2">Users</h3>
|
||
<div id="adminUsers" class="space-y-2 mb-6"></div>
|
||
<h3 class="text-sm font-semibold text-slate-300 mb-2">Copy station profiles between users</h3>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<select id="cloneFrom" class="inp !w-auto"></select>
|
||
<span class="text-slate-400">→</span>
|
||
<select id="cloneTo" class="inp !w-auto"></select>
|
||
<button id="btnClone" class="btn-primary">Copy all profiles</button>
|
||
</div>
|
||
<p id="adminMsg" class="text-xs mt-3"></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toasts" class="fixed bottom-4 right-4 z-[60] space-y-2 max-w-sm"></div>
|
||
|
||
<script>
|
||
'use strict';
|
||
// ------------------------------ helpers -----------------------------------
|
||
const $ = id => document.getElementById(id);
|
||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||
|
||
const state = {
|
||
user: null,
|
||
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;
|
||
let currentLimit = 25;
|
||
let totalTracks = 0;
|
||
|
||
async function api(url, opts = {}) {
|
||
const res = await fetch(url, {
|
||
credentials: 'same-origin',
|
||
...opts,
|
||
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||
body: opts.body && typeof opts.body !== 'string' ? JSON.stringify(opts.body) : opts.body,
|
||
});
|
||
if (res.status === 401 && !url.includes('/auth/')) { showAuth(); throw new Error('Session expired — please sign in again.'); }
|
||
let data = null;
|
||
if ((res.headers.get('content-type') || '').includes('application/json')) data = await res.json();
|
||
if (!res.ok) throw new Error((data && data.error) || `Request failed (${res.status})`);
|
||
return data;
|
||
}
|
||
|
||
function toast(msg, type = 'info') {
|
||
const colors = { info: 'bg-slate-700 text-slate-100', success: 'bg-emerald-600 text-white', error: 'bg-rose-600 text-white' };
|
||
const el = document.createElement('div');
|
||
el.className = `px-4 py-2.5 rounded-lg shadow-lg text-sm ${colors[type] || colors.info}`;
|
||
el.textContent = msg;
|
||
$('toasts').appendChild(el);
|
||
setTimeout(() => el.remove(), 4500);
|
||
}
|
||
|
||
function openModal(id) { $(id).classList.remove('hidden'); $(id).classList.add('flex'); }
|
||
function closeModal(id) { $(id).classList.add('hidden'); $(id).classList.remove('flex'); }
|
||
|
||
// ------------------------------ views -------------------------------------
|
||
function showAuth() {
|
||
state.user = null;
|
||
$('authView').classList.remove('hidden');
|
||
$('appView').classList.add('hidden');
|
||
$('navArea').classList.add('hidden');
|
||
$('navArea').classList.remove('flex');
|
||
}
|
||
|
||
function showApp() {
|
||
$('authView').classList.add('hidden');
|
||
$('appView').classList.remove('hidden');
|
||
$('navArea').classList.remove('hidden');
|
||
$('navArea').classList.add('flex');
|
||
$('userChip').textContent = `${state.user.username} · ${state.user.role}`;
|
||
$('btnAdmin').classList.toggle('hidden', state.user.role !== 'admin');
|
||
}
|
||
|
||
function setAuthMode(mode) {
|
||
state.authMode = mode;
|
||
const login = mode === 'login';
|
||
$('tabLogin').className = 'flex-1 py-2 text-sm font-semibold transition ' + (login ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-700/50');
|
||
$('tabRegister').className = 'flex-1 py-2 text-sm font-semibold transition ' + (!login ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-700/50');
|
||
$('authSubmit').textContent = login ? 'Sign in' : 'Create account';
|
||
$('authHint').classList.toggle('hidden', login);
|
||
$('authError').classList.add('hidden');
|
||
}
|
||
|
||
// ------------------------------ auth --------------------------------------
|
||
async function submitAuth(e) {
|
||
e.preventDefault();
|
||
$('authError').classList.add('hidden');
|
||
const username = $('authUsername').value.trim();
|
||
const password = $('authPassword').value;
|
||
try {
|
||
const data = await api(`/api/auth/${state.authMode}`, { method: 'POST', body: { username, password } });
|
||
state.user = data.user;
|
||
showApp();
|
||
await loadProfiles();
|
||
toast(`Welcome, ${data.user.username}${data.user.role === 'admin' ? ' (admin)' : ''}.`, 'success');
|
||
} catch (err) {
|
||
$('authError').textContent = err.message;
|
||
$('authError').classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
try { await api('/api/auth/logout', { method: 'POST' }); } catch { /* ignore */ }
|
||
location.reload();
|
||
}
|
||
|
||
// ---------------------------- profiles ------------------------------------
|
||
async function loadProfiles() {
|
||
const data = await api('/api/profiles');
|
||
state.profiles = data.profiles;
|
||
if (state.activeProfileId && !state.profiles.some(p => p.id === state.activeProfileId)) state.activeProfileId = null;
|
||
if (!state.activeProfileId && state.profiles.length) state.activeProfileId = state.profiles[0].id;
|
||
renderStationSelect();
|
||
renderProfilesList();
|
||
}
|
||
|
||
function renderStationSelect() {
|
||
const sel = $('stationSelect');
|
||
sel.innerHTML = state.profiles.length
|
||
? state.profiles.map(p => `<option value="${p.id}">${esc(p.name)} — ${esc(p.host)}/${esc(p.database)}</option>`).join('')
|
||
: '<option value="">No profiles — open Profiles</option>';
|
||
sel.value = state.activeProfileId ?? '';
|
||
}
|
||
|
||
function renderProfilesList() {
|
||
const box = $('profilesList');
|
||
if (!state.profiles.length) {
|
||
box.innerHTML = '<p class="text-sm text-slate-500">No station profiles yet — add one below.</p>';
|
||
return;
|
||
}
|
||
box.innerHTML = state.profiles.map(p => `
|
||
<div class="flex flex-wrap items-center gap-2 bg-slate-900/50 border ${p.id === state.activeProfileId ? 'border-indigo-500/60' : 'border-slate-700'} rounded-lg px-3 py-2" data-pid="${p.id}">
|
||
<div class="min-w-0">
|
||
<div class="font-semibold text-slate-100">${esc(p.name)} ${p.id === state.activeProfileId ? '<span class="text-xs text-indigo-400">(active)</span>' : ''}</div>
|
||
<div class="text-xs text-slate-400 font-mono">${esc(p.dbUser)}@${esc(p.host)}:${p.port}/${esc(p.database)}</div>
|
||
</div>
|
||
<div class="ml-auto flex gap-1.5">
|
||
<button type="button" class="btn-mini act-use">Set active</button>
|
||
<button type="button" class="btn-mini act-test">Test</button>
|
||
<button type="button" class="btn-mini act-edit">Edit</button>
|
||
<button type="button" class="btn-danger act-del">Delete</button>
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function resetProfileForm() {
|
||
$('pfId').value = '';
|
||
$('profileForm').reset();
|
||
$('pfPort').value = '';
|
||
$('pfPass').placeholder = 'DB password';
|
||
$('profileFormTitle').textContent = 'Add profile';
|
||
$('pfCancel').classList.add('hidden');
|
||
$('pfMsg').textContent = '';
|
||
}
|
||
|
||
async function submitProfile(e) {
|
||
e.preventDefault();
|
||
const id = $('pfId').value;
|
||
const body = {
|
||
name: $('pfName').value.trim(),
|
||
host: $('pfHost').value.trim(),
|
||
port: $('pfPort').value || '3306',
|
||
database: $('pfDb').value.trim(),
|
||
dbUser: $('pfUser').value.trim(),
|
||
dbPassword: $('pfPass').value,
|
||
};
|
||
try {
|
||
if (id) await api(`/api/profiles/${id}`, { method: 'PUT', body });
|
||
else await api('/api/profiles', { method: 'POST', body });
|
||
resetProfileForm();
|
||
await loadProfiles();
|
||
toast('Profile saved.', 'success');
|
||
} catch (err) {
|
||
$('pfMsg').textContent = err.message;
|
||
$('pfMsg').className = 'text-xs text-rose-400';
|
||
}
|
||
}
|
||
|
||
async function handleProfileAction(e) {
|
||
const btn = e.target.closest('button');
|
||
if (!btn) return;
|
||
const wrap = e.target.closest('[data-pid]');
|
||
if (!wrap) return;
|
||
const pid = Number(wrap.dataset.pid);
|
||
const profile = state.profiles.find(p => p.id === pid);
|
||
if (!profile) return;
|
||
|
||
if (btn.classList.contains('act-use')) {
|
||
state.activeProfileId = pid;
|
||
renderStationSelect();
|
||
renderProfilesList();
|
||
toast(`Active station: ${profile.name}`, 'success');
|
||
} else if (btn.classList.contains('act-test')) {
|
||
btn.disabled = true; btn.textContent = '…';
|
||
try {
|
||
const r = await api(`/api/profiles/${pid}/test`, { method: 'POST' });
|
||
toast(`Connected — MySQL ${r.version}, ${r.missingArtwork} track(s) missing artwork.`, 'success');
|
||
} catch (err) {
|
||
toast(err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Test';
|
||
}
|
||
} else if (btn.classList.contains('act-edit')) {
|
||
$('pfId').value = profile.id;
|
||
$('pfName').value = profile.name;
|
||
$('pfHost').value = profile.host;
|
||
$('pfPort').value = profile.port;
|
||
$('pfDb').value = profile.database;
|
||
$('pfUser').value = profile.dbUser;
|
||
$('pfPass').value = '';
|
||
$('pfPass').placeholder = 'Leave blank to keep current password';
|
||
$('profileFormTitle').textContent = `Edit profile: ${profile.name}`;
|
||
$('pfCancel').classList.remove('hidden');
|
||
} else if (btn.classList.contains('act-del')) {
|
||
if (!confirm(`Delete profile "${profile.name}"?`)) return;
|
||
try {
|
||
await api(`/api/profiles/${pid}`, { method: 'DELETE' });
|
||
if (state.activeProfileId === pid) state.activeProfileId = null;
|
||
await loadProfiles();
|
||
toast('Profile deleted.', 'success');
|
||
} catch (err) { toast(err.message, 'error'); }
|
||
}
|
||
}
|
||
|
||
// ----------------------------- tracks -------------------------------------
|
||
function sourceLabel(r) {
|
||
return r.sourceLabel || ({ itunes: 'iTunes API', deezer: 'Deezer', musicbrainz: 'Cover Art Archive / MusicBrainz' }[r.source] || r.source || 'Unknown');
|
||
}
|
||
|
||
function thumbHtml(r, selectedUrl) {
|
||
return `<img class="thumb w-14 h-14 rounded object-cover cursor-zoom-in border-2 ${selectedUrl === r.full ? 'border-indigo-500' : 'border-transparent hover:border-slate-500'}"
|
||
src="${esc(r.thumb)}" data-full="${esc(r.full)}" data-thumb="${esc(r.thumb)}" data-source-label="${esc(sourceLabel(r))}"
|
||
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">`
|
||
: `<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">${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.album, t.audioBase].join(' ').toLowerCase().includes(f));
|
||
$('tracksBody').innerHTML = rows.map(trackRowHtml).join('');
|
||
const empty = $('emptyState');
|
||
empty.style.display = rows.length ? 'none' : '';
|
||
empty.textContent = state.tracks.length
|
||
? 'No tracks match the filter.'
|
||
: 'No tracks loaded. Select a station profile, then click “Load tracks missing artwork”.';
|
||
$('checkAll').checked = false;
|
||
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
|
||
? `${state.tracks.length} track(s) on page (${totalTracks} total) · ${state.selections.size} with artwork · ${checked} selected`
|
||
: '';
|
||
$('btnExport').disabled = checked === 0;
|
||
$('btnAutoSearch').disabled = state.tracks.length === 0;
|
||
}
|
||
|
||
// --------------------------- pagination ------------------------------------
|
||
function renderPagination(totalPages) {
|
||
const bar = $('paginationBar');
|
||
if (!totalTracks) { bar.classList.add('hidden'); bar.innerHTML = ''; return; }
|
||
bar.classList.remove('hidden');
|
||
const cur = String(currentLimit);
|
||
let pages = '';
|
||
if (totalPages > 1) {
|
||
const lo = Math.max(1, Math.min(currentPage - 2, totalPages - 4));
|
||
const hi = Math.min(totalPages, Math.max(currentPage + 2, 5));
|
||
let html = '';
|
||
if (lo > 1) html += `<button type="button" data-page="1" class="btn-mini">1</button>` + (lo > 2 ? '<span class="px-1 text-slate-500">…</span>' : '');
|
||
for (let p = lo; p <= hi; p++) {
|
||
html += `<button type="button" data-page="${p}" class="btn-mini ${p === currentPage ? '!bg-indigo-600 !text-white' : ''}">${p}</button>`;
|
||
}
|
||
if (hi < totalPages) html += (hi < totalPages - 1 ? '<span class="px-1 text-slate-500">…</span>' : '') + `<button type="button" data-page="${totalPages}" class="btn-mini">${totalPages}</button>`;
|
||
pages = `<span class="flex items-center gap-1">${html}</span>`;
|
||
} else {
|
||
pages = '<span class="text-slate-400">Page 1 of 1</span>';
|
||
}
|
||
bar.innerHTML = `
|
||
<label class="text-slate-400">Per page</label>
|
||
<select id="limitSelect" class="inp !w-auto !py-1">
|
||
<option value="10" ${cur === '10' ? 'selected' : ''}>10</option>
|
||
<option value="25" ${cur === '25' ? 'selected' : ''}>25</option>
|
||
<option value="30" ${cur === '30' ? 'selected' : ''}>30</option>
|
||
<option value="50" ${cur === '50' ? 'selected' : ''}>50</option>
|
||
<option value="all" ${cur === 'all' ? 'selected' : ''}>all</option>
|
||
</select>
|
||
<button type="button" id="btnPrev" class="btn-mini" ${currentPage <= 1 ? 'disabled' : ''}>← Prev</button>
|
||
${pages}
|
||
<button type="button" id="btnNext" class="btn-mini" ${currentPage >= totalPages ? 'disabled' : ''}>Next →</button>`;
|
||
}
|
||
|
||
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}`);
|
||
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);
|
||
clearArtworkChoices();
|
||
renderTracks();
|
||
toast(`${data.tracks.length} track(s) missing artwork loaded.`, 'success');
|
||
} catch (err) {
|
||
toast(err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Load tracks missing artwork';
|
||
}
|
||
}
|
||
|
||
async function searchForRow(tr, track) {
|
||
const btn = tr.querySelector('.search-btn');
|
||
const box = tr.querySelector('.thumbs');
|
||
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 } });
|
||
state.candidates.set(track.id, data.results);
|
||
const sel = state.selections.get(track.id);
|
||
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 {
|
||
btn.disabled = false; btn.textContent = 'Search artwork';
|
||
}
|
||
}
|
||
|
||
function setTrackSelection(tr, track, img) {
|
||
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">`;
|
||
updateStats();
|
||
}
|
||
|
||
function selectThumb(tr, track, img) {
|
||
setTrackSelection(tr, track, img);
|
||
tr.querySelector('.row-check').checked = true;
|
||
updateStats();
|
||
}
|
||
|
||
// --------------------------- preview overlay -------------------------------
|
||
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 || '',
|
||
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)';
|
||
$('artArtist').textContent = t.artist || '';
|
||
$('artSource').textContent = imgData.sourceLabel ? `Source: ${imgData.sourceLabel}` : '';
|
||
$('artOriginal').href = imgData.full;
|
||
openModal('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) {
|
||
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 = 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">`;
|
||
}
|
||
|
||
async function autoSearch() {
|
||
const pending = state.tracks.filter(t => !state.selections.has(t.id));
|
||
if (!pending.length) return toast('Every loaded track already has artwork selected.');
|
||
const btn = $('btnAutoSearch');
|
||
btn.disabled = true;
|
||
let done = 0, found = 0;
|
||
for (const t of pending) {
|
||
done += 1;
|
||
btn.textContent = `Searching ${done}/${pending.length}…`;
|
||
try {
|
||
const data = await api('/api/artwork/search', { method: 'POST', body: { artist: t.artist, title: t.title, album: t.album, source: $('apiSourceSelect').value } });
|
||
if (data.results.length) {
|
||
found += 1;
|
||
const tr = document.querySelector(`tr[data-id="${t.id}"]`);
|
||
if (tr) fillRowFromBest(tr, t, data.results[0]);
|
||
}
|
||
} catch { /* keep going */ }
|
||
await sleep(250); // be polite to the free APIs
|
||
}
|
||
btn.textContent = 'Fetch Art for Current Page';
|
||
btn.disabled = false;
|
||
updateStats();
|
||
toast(`Fetched artwork suggestions for ${found}/${pending.length} track(s) — review each one and tick its checkbox to confirm.`, found ? 'success' : 'info');
|
||
}
|
||
|
||
// ----------------------------- 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.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',
|
||
body: formData,
|
||
});
|
||
if (!res.ok) {
|
||
let msg = `Export failed (${res.status})`;
|
||
try { msg = (await res.json()).error || msg; } catch { /* ignore */ }
|
||
throw new Error(msg);
|
||
}
|
||
const blob = await res.blob();
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `rdj-artwork-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.zip`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(a.href);
|
||
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 {
|
||
btn.disabled = false; btn.textContent = 'Download ZIP & Update Database';
|
||
updateStats();
|
||
}
|
||
}
|
||
|
||
// ------------------------------ admin -------------------------------------
|
||
async function loadAdmin() {
|
||
const data = await api('/api/admin/users');
|
||
$('adminUsers').innerHTML = data.users.map(u => `
|
||
<div class="flex items-center gap-2 bg-slate-900/50 border border-slate-700 rounded-lg px-3 py-2" data-uid="${u.id}">
|
||
<span class="font-medium text-slate-100">${esc(u.username)}</span>
|
||
<span class="text-xs text-slate-500">${u.profileCount} profile(s) · joined ${esc(String(u.createdAt).slice(0, 10))}</span>
|
||
<select class="role-sel inp !w-auto !py-1 ml-auto" ${u.id === state.user.id ? 'disabled title="You cannot change your own role"' : ''}>
|
||
<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>
|
||
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>
|
||
</select>
|
||
</div>`).join('');
|
||
const opts = data.users.map(u => `<option value="${u.id}">${esc(u.username)}</option>`).join('');
|
||
$('cloneFrom').innerHTML = opts;
|
||
$('cloneTo').innerHTML = opts;
|
||
if (data.users.length > 1) $('cloneTo').selectedIndex = 1;
|
||
}
|
||
|
||
async function handleRoleChange(e) {
|
||
if (!e.target.classList.contains('role-sel')) return;
|
||
const uid = Number(e.target.closest('[data-uid]').dataset.uid);
|
||
try {
|
||
await api(`/api/admin/users/${uid}/role`, { method: 'PUT', body: { role: e.target.value } });
|
||
toast('Role updated.', 'success');
|
||
} catch (err) {
|
||
toast(err.message, 'error');
|
||
await loadAdmin();
|
||
}
|
||
}
|
||
|
||
async function cloneProfiles() {
|
||
const fromUserId = Number($('cloneFrom').value);
|
||
const toUserId = Number($('cloneTo').value);
|
||
$('adminMsg').textContent = '';
|
||
try {
|
||
const r = await api('/api/admin/clone-profiles', { method: 'POST', body: { fromUserId, toUserId } });
|
||
$('adminMsg').className = 'text-xs mt-3 text-emerald-400';
|
||
$('adminMsg').textContent = `Copied ${r.cloned} profile(s).`;
|
||
await loadAdmin();
|
||
} catch (err) {
|
||
$('adminMsg').className = 'text-xs mt-3 text-rose-400';
|
||
$('adminMsg').textContent = err.message;
|
||
}
|
||
}
|
||
|
||
// ------------------------------ events ------------------------------------
|
||
function bindEvents() {
|
||
$('tabLogin').addEventListener('click', () => setAuthMode('login'));
|
||
$('tabRegister').addEventListener('click', () => setAuthMode('register'));
|
||
$('authForm').addEventListener('submit', submitAuth);
|
||
$('btnLogout').addEventListener('click', logout);
|
||
|
||
$('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();
|
||
});
|
||
|
||
$('btnStations').addEventListener('click', () => { renderProfilesList(); openModal('profilesModal'); });
|
||
$('btnAdmin').addEventListener('click', async () => {
|
||
openModal('adminModal');
|
||
try { await loadAdmin(); } catch (err) { toast(err.message, 'error'); }
|
||
});
|
||
document.querySelectorAll('[data-close]').forEach(b =>
|
||
b.addEventListener('click', () => closeModal(b.dataset.close)));
|
||
document.querySelectorAll('.modal').forEach(m =>
|
||
m.addEventListener('click', e => { if (e.target === m) closeModal(m.id); }));
|
||
|
||
$('profileForm').addEventListener('submit', submitProfile);
|
||
$('pfCancel').addEventListener('click', resetProfileForm);
|
||
$('profilesList').addEventListener('click', handleProfileAction);
|
||
|
||
$('btnLoadTracks').addEventListener('click', () => loadTracks({ resetMetadataEdits: true }));
|
||
$('btnAutoSearch').addEventListener('click', autoSearch);
|
||
$('filterInput').addEventListener('input', renderTracks);
|
||
$('btnExport').addEventListener('click', exportSelected);
|
||
|
||
$('checkAll').addEventListener('change', e => {
|
||
const on = e.target.checked;
|
||
document.querySelectorAll('#tracksBody .row-check').forEach(cb => {
|
||
const id = Number(cb.closest('tr').dataset.id);
|
||
cb.checked = on ? state.selections.has(id) : false;
|
||
});
|
||
updateStats();
|
||
});
|
||
|
||
$('tracksBody').addEventListener('click', async e => {
|
||
const tr = e.target.closest('tr[data-id]');
|
||
if (!tr) return;
|
||
const track = state.tracks.find(t => t.id === Number(tr.dataset.id));
|
||
if (!track) return;
|
||
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),
|
||
});
|
||
}
|
||
});
|
||
|
||
$('tracksBody').addEventListener('change', e => {
|
||
if (!e.target.classList.contains('row-check')) return;
|
||
const id = Number(e.target.closest('tr').dataset.id);
|
||
if (e.target.checked && !state.selections.has(id)) {
|
||
e.target.checked = false;
|
||
toast('Choose an artwork result for this track first.', 'error');
|
||
}
|
||
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);
|
||
|
||
// Pagination bar (delegated — contents re-render on every load)
|
||
$('paginationBar').addEventListener('change', e => {
|
||
if (e.target.id !== 'limitSelect') return;
|
||
currentLimit = e.target.value === 'all' ? 'all' : parseInt(e.target.value, 10);
|
||
currentPage = 1;
|
||
loadTracks();
|
||
});
|
||
$('paginationBar').addEventListener('click', e => {
|
||
const b = e.target.closest('button');
|
||
if (!b || b.disabled) return;
|
||
if (b.id === 'btnPrev') currentPage -= 1;
|
||
else if (b.id === 'btnNext') currentPage += 1;
|
||
else if (b.dataset.page) currentPage = parseInt(b.dataset.page, 10);
|
||
else return;
|
||
loadTracks();
|
||
});
|
||
|
||
// Artwork preview overlay
|
||
$('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');
|
||
});
|
||
}
|
||
|
||
// ------------------------------- init -------------------------------------
|
||
(async function init() {
|
||
bindEvents();
|
||
setAuthMode('login');
|
||
try {
|
||
const { user } = await api('/api/auth/me');
|
||
state.user = user;
|
||
showApp();
|
||
await loadProfiles();
|
||
} catch {
|
||
showAuth();
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|