622 lines
17 KiB
HTML
622 lines
17 KiB
HTML
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>Furumi Agent — Admin</title>
|
||
|
|
<style>
|
||
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||
|
|
|
||
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
|
|
||
|
|
:root {
|
||
|
|
--bg-base: #0a0c12;
|
||
|
|
--bg-panel: #111520;
|
||
|
|
--bg-card: #161d2e;
|
||
|
|
--bg-hover: #1e2740;
|
||
|
|
--bg-active: #252f4a;
|
||
|
|
--border: #1f2c45;
|
||
|
|
--accent: #7c6af7;
|
||
|
|
--accent-dim: #5a4fcf;
|
||
|
|
--text: #e2e8f0;
|
||
|
|
--text-muted: #64748b;
|
||
|
|
--text-dim: #94a3b8;
|
||
|
|
--success: #34d399;
|
||
|
|
--danger: #f87171;
|
||
|
|
--warning: #fbbf24;
|
||
|
|
}
|
||
|
|
|
||
|
|
html, body { height: 100%; overflow: hidden; }
|
||
|
|
|
||
|
|
body {
|
||
|
|
font-family: 'Inter', sans-serif;
|
||
|
|
background: var(--bg-base);
|
||
|
|
color: var(--text);
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
}
|
||
|
|
|
||
|
|
header {
|
||
|
|
background: var(--bg-panel);
|
||
|
|
border-bottom: 1px solid var(--border);
|
||
|
|
padding: 12px 24px;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 24px;
|
||
|
|
}
|
||
|
|
|
||
|
|
header h1 {
|
||
|
|
font-size: 16px;
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stats {
|
||
|
|
display: flex;
|
||
|
|
gap: 16px;
|
||
|
|
margin-left: auto;
|
||
|
|
font-size: 13px;
|
||
|
|
color: var(--text-dim);
|
||
|
|
}
|
||
|
|
|
||
|
|
.stats .stat { display: flex; gap: 4px; align-items: center; }
|
||
|
|
.stats .stat-value { color: var(--text); font-weight: 600; }
|
||
|
|
|
||
|
|
nav {
|
||
|
|
display: flex;
|
||
|
|
gap: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
nav button {
|
||
|
|
background: none;
|
||
|
|
border: none;
|
||
|
|
color: var(--text-muted);
|
||
|
|
padding: 6px 12px;
|
||
|
|
border-radius: 6px;
|
||
|
|
cursor: pointer;
|
||
|
|
font-size: 13px;
|
||
|
|
font-family: inherit;
|
||
|
|
}
|
||
|
|
|
||
|
|
nav button:hover { background: var(--bg-hover); color: var(--text); }
|
||
|
|
nav button.active { background: var(--bg-active); color: var(--accent); }
|
||
|
|
|
||
|
|
main {
|
||
|
|
flex: 1;
|
||
|
|
overflow-y: auto;
|
||
|
|
padding: 16px 24px;
|
||
|
|
}
|
||
|
|
|
||
|
|
table {
|
||
|
|
width: 100%;
|
||
|
|
border-collapse: collapse;
|
||
|
|
font-size: 13px;
|
||
|
|
}
|
||
|
|
|
||
|
|
th {
|
||
|
|
text-align: left;
|
||
|
|
padding: 8px 12px;
|
||
|
|
color: var(--text-muted);
|
||
|
|
font-weight: 500;
|
||
|
|
border-bottom: 1px solid var(--border);
|
||
|
|
position: sticky;
|
||
|
|
top: 0;
|
||
|
|
background: var(--bg-base);
|
||
|
|
}
|
||
|
|
|
||
|
|
td {
|
||
|
|
padding: 8px 12px;
|
||
|
|
border-bottom: 1px solid var(--border);
|
||
|
|
max-width: 200px;
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
tr:hover td { background: var(--bg-hover); }
|
||
|
|
|
||
|
|
.status {
|
||
|
|
padding: 2px 8px;
|
||
|
|
border-radius: 4px;
|
||
|
|
font-size: 11px;
|
||
|
|
font-weight: 600;
|
||
|
|
text-transform: uppercase;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-pending { background: #1e293b; color: var(--text-muted); }
|
||
|
|
.status-processing { background: #1e1b4b; color: var(--accent); }
|
||
|
|
.status-review { background: #422006; color: var(--warning); }
|
||
|
|
.status-approved { background: #052e16; color: var(--success); }
|
||
|
|
.status-rejected { background: #450a0a; color: var(--danger); }
|
||
|
|
.status-error { background: #450a0a; color: var(--danger); }
|
||
|
|
|
||
|
|
.actions {
|
||
|
|
display: flex;
|
||
|
|
gap: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn {
|
||
|
|
border: none;
|
||
|
|
padding: 4px 10px;
|
||
|
|
border-radius: 4px;
|
||
|
|
cursor: pointer;
|
||
|
|
font-size: 12px;
|
||
|
|
font-family: inherit;
|
||
|
|
font-weight: 500;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-approve { background: #052e16; color: var(--success); }
|
||
|
|
.btn-approve:hover { background: #065f46; }
|
||
|
|
.btn-reject { background: #450a0a; color: var(--danger); }
|
||
|
|
.btn-reject:hover { background: #7f1d1d; }
|
||
|
|
.btn-edit { background: var(--bg-active); color: var(--text-dim); }
|
||
|
|
.btn-edit:hover { background: var(--bg-hover); color: var(--text); }
|
||
|
|
|
||
|
|
.empty {
|
||
|
|
text-align: center;
|
||
|
|
padding: 48px;
|
||
|
|
color: var(--text-muted);
|
||
|
|
font-size: 14px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Modal */
|
||
|
|
.modal-overlay {
|
||
|
|
display: none;
|
||
|
|
position: fixed;
|
||
|
|
inset: 0;
|
||
|
|
background: rgba(0,0,0,0.7);
|
||
|
|
z-index: 100;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.modal-overlay.visible { display: flex; }
|
||
|
|
|
||
|
|
.modal {
|
||
|
|
background: var(--bg-panel);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 12px;
|
||
|
|
padding: 24px;
|
||
|
|
min-width: 400px;
|
||
|
|
max-width: 600px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.modal h2 { font-size: 16px; margin-bottom: 16px; }
|
||
|
|
|
||
|
|
.modal label {
|
||
|
|
display: block;
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-muted);
|
||
|
|
margin-bottom: 4px;
|
||
|
|
margin-top: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.modal input, .modal textarea {
|
||
|
|
width: 100%;
|
||
|
|
background: var(--bg-card);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 6px;
|
||
|
|
padding: 8px 10px;
|
||
|
|
color: var(--text);
|
||
|
|
font-family: inherit;
|
||
|
|
font-size: 13px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.modal textarea { resize: vertical; min-height: 60px; }
|
||
|
|
|
||
|
|
.modal-actions {
|
||
|
|
margin-top: 20px;
|
||
|
|
display: flex;
|
||
|
|
gap: 8px;
|
||
|
|
justify-content: flex-end;
|
||
|
|
}
|
||
|
|
|
||
|
|
.modal-actions .btn {
|
||
|
|
padding: 8px 16px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-primary { background: var(--accent); color: white; }
|
||
|
|
.btn-primary:hover { background: var(--accent-dim); }
|
||
|
|
.btn-cancel { background: var(--bg-card); color: var(--text-dim); }
|
||
|
|
.btn-cancel:hover { background: var(--bg-hover); }
|
||
|
|
|
||
|
|
/* Detail fields in modal */
|
||
|
|
.detail-row {
|
||
|
|
display: flex;
|
||
|
|
gap: 12px;
|
||
|
|
margin-top: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.detail-row .field { flex: 1; }
|
||
|
|
|
||
|
|
.raw-value {
|
||
|
|
font-size: 11px;
|
||
|
|
color: var(--text-muted);
|
||
|
|
margin-top: 2px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Featured artists tags */
|
||
|
|
.feat-tags {
|
||
|
|
display: flex;
|
||
|
|
flex-wrap: wrap;
|
||
|
|
gap: 6px;
|
||
|
|
margin-top: 6px;
|
||
|
|
min-height: 28px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.feat-tag {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 4px;
|
||
|
|
background: var(--bg-active);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 4px;
|
||
|
|
padding: 2px 8px;
|
||
|
|
font-size: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.feat-tag .remove {
|
||
|
|
cursor: pointer;
|
||
|
|
color: var(--text-muted);
|
||
|
|
font-size: 14px;
|
||
|
|
line-height: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.feat-tag .remove:hover { color: var(--danger); }
|
||
|
|
|
||
|
|
/* Artist search dropdown */
|
||
|
|
.artist-search-wrap {
|
||
|
|
position: relative;
|
||
|
|
margin-top: 6px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.artist-search-wrap input {
|
||
|
|
width: 100%;
|
||
|
|
}
|
||
|
|
|
||
|
|
.artist-dropdown {
|
||
|
|
position: absolute;
|
||
|
|
top: 100%;
|
||
|
|
left: 0;
|
||
|
|
right: 0;
|
||
|
|
background: var(--bg-card);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 0 0 6px 6px;
|
||
|
|
max-height: 160px;
|
||
|
|
overflow-y: auto;
|
||
|
|
z-index: 10;
|
||
|
|
display: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.artist-dropdown.open { display: block; }
|
||
|
|
|
||
|
|
.artist-option {
|
||
|
|
padding: 6px 10px;
|
||
|
|
cursor: pointer;
|
||
|
|
font-size: 13px;
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
}
|
||
|
|
|
||
|
|
.artist-option:hover { background: var(--bg-hover); }
|
||
|
|
|
||
|
|
.artist-option .sim {
|
||
|
|
color: var(--text-muted);
|
||
|
|
font-size: 11px;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
|
||
|
|
<header>
|
||
|
|
<h1>Furumi Agent</h1>
|
||
|
|
<nav>
|
||
|
|
<button class="active" onclick="showTab('queue')">Queue</button>
|
||
|
|
<button onclick="showTab('artists')">Artists</button>
|
||
|
|
</nav>
|
||
|
|
<div class="stats" id="statsBar"></div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<main id="content"></main>
|
||
|
|
|
||
|
|
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
|
||
|
|
<div class="modal" id="modal"></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
const API = '/api';
|
||
|
|
let currentTab = 'queue';
|
||
|
|
let currentFilter = null;
|
||
|
|
|
||
|
|
async function api(path, opts) {
|
||
|
|
const r = await fetch(API + path, opts);
|
||
|
|
if (r.status === 204) return null;
|
||
|
|
const text = await r.text();
|
||
|
|
if (!text) return null;
|
||
|
|
try { return JSON.parse(text); }
|
||
|
|
catch(e) { console.error('API parse error:', r.status, text); return null; }
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadStats() {
|
||
|
|
const s = await api('/stats');
|
||
|
|
document.getElementById('statsBar').innerHTML = `
|
||
|
|
<div class="stat">Tracks: <span class="stat-value">${s.total_tracks}</span></div>
|
||
|
|
<div class="stat">Artists: <span class="stat-value">${s.total_artists}</span></div>
|
||
|
|
<div class="stat">Albums: <span class="stat-value">${s.total_albums}</span></div>
|
||
|
|
<div class="stat">Pending: <span class="stat-value">${s.pending_count}</span></div>
|
||
|
|
<div class="stat">Review: <span class="stat-value">${s.review_count}</span></div>
|
||
|
|
<div class="stat">Errors: <span class="stat-value">${s.error_count}</span></div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function showTab(tab) {
|
||
|
|
currentTab = tab;
|
||
|
|
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
||
|
|
event.target.classList.add('active');
|
||
|
|
if (tab === 'queue') loadQueue();
|
||
|
|
else if (tab === 'artists') loadArtists();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadQueue(status) {
|
||
|
|
currentFilter = status;
|
||
|
|
const qs = status ? `?status=${status}` : '';
|
||
|
|
const items = await api(`/queue${qs}`);
|
||
|
|
const el = document.getElementById('content');
|
||
|
|
|
||
|
|
if (!items.length) {
|
||
|
|
el.innerHTML = '<div class="empty">No items in queue</div>';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let html = `
|
||
|
|
<div style="margin-bottom:12px;display:flex;gap:4px">
|
||
|
|
<button class="btn ${!status?'btn-primary':'btn-edit'}" onclick="loadQueue()">All</button>
|
||
|
|
<button class="btn ${status==='review'?'btn-primary':'btn-edit'}" onclick="loadQueue('review')">Review</button>
|
||
|
|
<button class="btn ${status==='pending'?'btn-primary':'btn-edit'}" onclick="loadQueue('pending')">Pending</button>
|
||
|
|
<button class="btn ${status==='approved'?'btn-primary':'btn-edit'}" onclick="loadQueue('approved')">Approved</button>
|
||
|
|
<button class="btn ${status==='error'?'btn-primary':'btn-edit'}" onclick="loadQueue('error')">Errors</button>
|
||
|
|
</div>
|
||
|
|
<table>
|
||
|
|
<tr><th>Status</th><th>Raw Artist</th><th>Raw Title</th><th>Norm Artist</th><th>Norm Title</th><th>Norm Album</th><th>Conf</th><th>Actions</th></tr>
|
||
|
|
`;
|
||
|
|
|
||
|
|
for (const it of items) {
|
||
|
|
const conf = it.confidence != null ? it.confidence.toFixed(2) : '-';
|
||
|
|
html += `<tr>
|
||
|
|
<td><span class="status status-${it.status}">${it.status}</span></td>
|
||
|
|
<td title="${esc(it.raw_artist)}">${esc(it.raw_artist || '-')}</td>
|
||
|
|
<td title="${esc(it.raw_title)}">${esc(it.raw_title || '-')}</td>
|
||
|
|
<td title="${esc(it.norm_artist)}">${esc(it.norm_artist || '-')}</td>
|
||
|
|
<td title="${esc(it.norm_title)}">${esc(it.norm_title || '-')}</td>
|
||
|
|
<td title="${esc(it.norm_album)}">${esc(it.norm_album || '-')}</td>
|
||
|
|
<td>${conf}</td>
|
||
|
|
<td class="actions">
|
||
|
|
${it.status === 'review' ? `<button class="btn btn-approve" onclick="approveItem('${it.id}')">Approve</button>` : ''}
|
||
|
|
${it.status === 'review' ? `<button class="btn btn-reject" onclick="rejectItem('${it.id}')">Reject</button>` : ''}
|
||
|
|
<button class="btn btn-edit" onclick="editItem('${it.id}')">Edit</button>
|
||
|
|
</td>
|
||
|
|
</tr>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
html += '</table>';
|
||
|
|
el.innerHTML = html;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadArtists() {
|
||
|
|
const artists = await api('/artists');
|
||
|
|
const el = document.getElementById('content');
|
||
|
|
|
||
|
|
if (!artists.length) {
|
||
|
|
el.innerHTML = '<div class="empty">No artists yet</div>';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let html = '<table><tr><th>ID</th><th>Name</th><th>Actions</th></tr>';
|
||
|
|
for (const a of artists) {
|
||
|
|
html += `<tr>
|
||
|
|
<td>${a.id}</td>
|
||
|
|
<td>${esc(a.name)}</td>
|
||
|
|
<td class="actions">
|
||
|
|
<button class="btn btn-edit" onclick="editArtist(${a.id}, '${esc(a.name)}')">Rename</button>
|
||
|
|
</td>
|
||
|
|
</tr>`;
|
||
|
|
}
|
||
|
|
html += '</table>';
|
||
|
|
el.innerHTML = html;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function approveItem(id) {
|
||
|
|
await api(`/queue/${id}/approve`, { method: 'POST' });
|
||
|
|
loadStats();
|
||
|
|
loadQueue(currentFilter);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function rejectItem(id) {
|
||
|
|
await api(`/queue/${id}/reject`, { method: 'POST' });
|
||
|
|
loadStats();
|
||
|
|
loadQueue(currentFilter);
|
||
|
|
}
|
||
|
|
|
||
|
|
let editFeatured = [];
|
||
|
|
let searchTimer = null;
|
||
|
|
|
||
|
|
async function editItem(id) {
|
||
|
|
const item = await api(`/queue/${id}`);
|
||
|
|
if (!item) return;
|
||
|
|
|
||
|
|
// Parse featured artists from JSON string
|
||
|
|
editFeatured = [];
|
||
|
|
if (item.norm_featured_artists) {
|
||
|
|
try { editFeatured = JSON.parse(item.norm_featured_artists); } catch(e) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
document.getElementById('modal').innerHTML = `
|
||
|
|
<h2>Edit Metadata</h2>
|
||
|
|
<div class="detail-row">
|
||
|
|
<div class="field">
|
||
|
|
<label>Artist</label>
|
||
|
|
<input id="ed-artist" value="${esc(item.norm_artist || item.raw_artist || '')}">
|
||
|
|
<div class="raw-value">Raw: ${esc(item.raw_artist || '-')} | Path: ${esc(item.path_artist || '-')}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="detail-row">
|
||
|
|
<div class="field">
|
||
|
|
<label>Title</label>
|
||
|
|
<input id="ed-title" value="${esc(item.norm_title || item.raw_title || '')}">
|
||
|
|
<div class="raw-value">Raw: ${esc(item.raw_title || '-')} | Path: ${esc(item.path_title || '-')}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="detail-row">
|
||
|
|
<div class="field">
|
||
|
|
<label>Album</label>
|
||
|
|
<input id="ed-album" value="${esc(item.norm_album || item.raw_album || '')}">
|
||
|
|
<div class="raw-value">Raw: ${esc(item.raw_album || '-')} | Path: ${esc(item.path_album || '-')}</div>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label>Year</label>
|
||
|
|
<input id="ed-year" type="number" value="${item.norm_year || item.raw_year || ''}">
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="detail-row">
|
||
|
|
<div class="field">
|
||
|
|
<label>Track #</label>
|
||
|
|
<input id="ed-track" type="number" value="${item.norm_track_number || item.raw_track_number || ''}">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label>Genre</label>
|
||
|
|
<input id="ed-genre" value="${esc(item.norm_genre || item.raw_genre || '')}">
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<label>Featured Artists</label>
|
||
|
|
<div class="feat-tags" id="feat-tags"></div>
|
||
|
|
<div class="artist-search-wrap">
|
||
|
|
<input id="feat-search" placeholder="Search artist to add..." autocomplete="off"
|
||
|
|
oninput="onFeatSearch(this.value)" onkeydown="onFeatKey(event)">
|
||
|
|
<div class="artist-dropdown" id="feat-dropdown"></div>
|
||
|
|
</div>
|
||
|
|
${item.llm_notes ? `<label>Agent Notes</label><div class="raw-value" style="margin-bottom:8px">${esc(item.llm_notes)}</div>` : ''}
|
||
|
|
${item.error_message ? `<label>Error</label><div class="raw-value" style="color:var(--danger)">${esc(item.error_message)}</div>` : ''}
|
||
|
|
<div class="modal-actions">
|
||
|
|
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
|
||
|
|
<button class="btn btn-primary" onclick="saveEdit('${item.id}')">Save</button>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
renderFeatTags();
|
||
|
|
openModal();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderFeatTags() {
|
||
|
|
const el = document.getElementById('feat-tags');
|
||
|
|
if (!el) return;
|
||
|
|
el.innerHTML = editFeatured.map((name, i) =>
|
||
|
|
`<span class="feat-tag">${esc(name)}<span class="remove" onclick="removeFeat(${i})">×</span></span>`
|
||
|
|
).join('');
|
||
|
|
}
|
||
|
|
|
||
|
|
function removeFeat(idx) {
|
||
|
|
editFeatured.splice(idx, 1);
|
||
|
|
renderFeatTags();
|
||
|
|
}
|
||
|
|
|
||
|
|
function addFeat(name) {
|
||
|
|
name = name.trim();
|
||
|
|
if (!name || editFeatured.includes(name)) return;
|
||
|
|
editFeatured.push(name);
|
||
|
|
renderFeatTags();
|
||
|
|
const input = document.getElementById('feat-search');
|
||
|
|
if (input) { input.value = ''; }
|
||
|
|
closeFeatDropdown();
|
||
|
|
}
|
||
|
|
|
||
|
|
function onFeatSearch(q) {
|
||
|
|
clearTimeout(searchTimer);
|
||
|
|
if (q.length < 2) { closeFeatDropdown(); return; }
|
||
|
|
searchTimer = setTimeout(async () => {
|
||
|
|
const results = await api(`/artists/search?q=${encodeURIComponent(q)}&limit=8`);
|
||
|
|
const dd = document.getElementById('feat-dropdown');
|
||
|
|
if (!results || !results.length) {
|
||
|
|
// Show option to add as new
|
||
|
|
dd.innerHTML = `<div class="artist-option" onclick="addFeat('${esc(q)}')">
|
||
|
|
Add "${esc(q)}" as new
|
||
|
|
</div>`;
|
||
|
|
dd.classList.add('open');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let html = '';
|
||
|
|
for (const a of results) {
|
||
|
|
html += `<div class="artist-option" onclick="addFeat('${esc(a.name)}')">
|
||
|
|
${esc(a.name)}
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
// Always offer to add typed value as-is
|
||
|
|
const typed = document.getElementById('feat-search').value.trim();
|
||
|
|
if (typed && !results.find(a => a.name.toLowerCase() === typed.toLowerCase())) {
|
||
|
|
html += `<div class="artist-option" onclick="addFeat('${esc(typed)}')">
|
||
|
|
Add "${esc(typed)}" as new
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
dd.innerHTML = html;
|
||
|
|
dd.classList.add('open');
|
||
|
|
}, 250);
|
||
|
|
}
|
||
|
|
|
||
|
|
function onFeatKey(e) {
|
||
|
|
if (e.key === 'Enter') {
|
||
|
|
e.preventDefault();
|
||
|
|
const val = e.target.value.trim();
|
||
|
|
if (val) addFeat(val);
|
||
|
|
} else if (e.key === 'Escape') {
|
||
|
|
closeFeatDropdown();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function closeFeatDropdown() {
|
||
|
|
const dd = document.getElementById('feat-dropdown');
|
||
|
|
if (dd) dd.classList.remove('open');
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveEdit(id) {
|
||
|
|
const body = {
|
||
|
|
norm_artist: document.getElementById('ed-artist').value || null,
|
||
|
|
norm_title: document.getElementById('ed-title').value || null,
|
||
|
|
norm_album: document.getElementById('ed-album').value || null,
|
||
|
|
norm_year: parseInt(document.getElementById('ed-year').value) || null,
|
||
|
|
norm_track_number: parseInt(document.getElementById('ed-track').value) || null,
|
||
|
|
norm_genre: document.getElementById('ed-genre').value || null,
|
||
|
|
featured_artists: editFeatured,
|
||
|
|
};
|
||
|
|
await api(`/queue/${id}/update`, {
|
||
|
|
method: 'PUT',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify(body),
|
||
|
|
});
|
||
|
|
closeModal();
|
||
|
|
loadQueue(currentFilter);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function editArtist(id, currentName) {
|
||
|
|
const name = prompt('New artist name:', currentName);
|
||
|
|
if (!name || name === currentName) return;
|
||
|
|
await api(`/artists/${id}`, {
|
||
|
|
method: 'PUT',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ name }),
|
||
|
|
});
|
||
|
|
loadArtists();
|
||
|
|
}
|
||
|
|
|
||
|
|
function openModal() { document.getElementById('modalOverlay').classList.add('visible'); }
|
||
|
|
function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }
|
||
|
|
|
||
|
|
function esc(s) {
|
||
|
|
if (s == null) return '';
|
||
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Init
|
||
|
|
loadStats();
|
||
|
|
loadQueue();
|
||
|
|
setInterval(loadStats, 10000);
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|