mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 21:01:40 +00:00
The cover picker only offered CAA covers from a song's MATCHED release, so an
unmatched song (the city-pop pile) got nothing but Current/Pack/Upload/URL. Add
a search box: GET /api/song/{fn}/art/cover-search?q= searches MusicBrainz
release-groups and returns each album's CAA front-250 thumb; the picker renders
them as pickable tiles (same apply→/art/url path; covers with no CAA art
self-hide). Pre-filled from the song's artist + album/title (romaji fallback), so
a blank-artist pack pre-fills "Junko Yagami …". Reuses the throttled _mb_http_get.
351 lines
19 KiB
JavaScript
351 lines
19 KiB
JavaScript
// Cover-art picker (PR-C — multi-candidate "change cover", media-server
|
||
// style). ONE component: window.__fbOpenImagePicker({filename, title}),
|
||
// reached from the Details drawer's art click and the card ⋮ "Change cover…".
|
||
//
|
||
// Anatomy mirrors match-review.js (body-appended singleton: overlay +
|
||
// centred panel, light focus trap, Esc closes, overlay click closes) but
|
||
// layers at z-[200] — the songs.js centered-modal tier — because one of its
|
||
// openers is the details drawer (z-[61]), which sits above match-review's
|
||
// z-40/50 pair.
|
||
//
|
||
// The design's key trick (§7-§9/§11 of the launch charrette): a pick never
|
||
// grows a new write path. Choosing a CAA candidate POSTs its thumb URL to
|
||
// the EXISTING …/art/url route (the override lane: never evicted, survives
|
||
// a re-match); "Pack original" DELETEs the override; Upload POSTs the
|
||
// existing …/art/upload (GIF stays upload-only + local-only; the server's
|
||
// 10MB / http(s) guards apply to URLs). Success is silent (hearing-safe,
|
||
// like the match layer): the modal just closes and the art refreshes.
|
||
(function () {
|
||
'use strict';
|
||
|
||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
const enc = encodeURIComponent;
|
||
|
||
// Provenance badge text — same vocabulary as the match layer.
|
||
const PROV_LABEL = { yours: 'Yours', pack: 'Pack', matched: 'Matched' };
|
||
|
||
let _cur = null; // {filename, title} while the picker is open
|
||
let _abort = null; // in-flight candidates fetch — cancelled on close
|
||
let _busy = false; // an apply is running — ignore further tile clicks
|
||
let _lastFocus = null;
|
||
|
||
const artBase = (fn) => '/api/song/' + enc(fn) + '/art';
|
||
|
||
// Post-apply refresh — the grid's cache-buster idiom (`?v=`): re-src
|
||
// every rendered <img> pointing at this song's art with a fresh v so the
|
||
// new pick paints everywhere it's currently shown (grid card, drawer
|
||
// preview, list row) without a full reload.
|
||
function refreshArt(fn) {
|
||
const base = artBase(fn);
|
||
document.querySelectorAll('img').forEach((img) => {
|
||
const src = img.getAttribute('src') || '';
|
||
if (src.split('?')[0] === base) {
|
||
img.src = base + '?v=' + Date.now();
|
||
img.style.visibility = 'visible';
|
||
}
|
||
});
|
||
}
|
||
|
||
function ensureModal() {
|
||
let m = document.getElementById('v3-imgpick-modal');
|
||
if (m) return m;
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'v3-imgpick-overlay';
|
||
overlay.className = 'fixed inset-0 bg-black/60 z-[200] hidden';
|
||
overlay.addEventListener('click', close);
|
||
document.body.appendChild(overlay);
|
||
m = document.createElement('div');
|
||
m.id = 'v3-imgpick-modal';
|
||
// Appended after the overlay: same z tier, DOM order paints it above.
|
||
m.className = 'fixed inset-0 z-[200] hidden flex items-center justify-center p-4 pointer-events-none';
|
||
m.innerHTML = '<div id="v3-imgpick-panel" class="pointer-events-auto w-full max-w-2xl max-h-[85vh] bg-fb-sidebar border border-fb-border/50 rounded-xl shadow-2xl flex flex-col" role="dialog" aria-label="Change cover"></div>';
|
||
m.addEventListener('keydown', onKeydown);
|
||
document.body.appendChild(m);
|
||
return m;
|
||
}
|
||
|
||
function onKeydown(e) {
|
||
if (e.key === 'Escape') { e.stopPropagation(); close(); return; }
|
||
if (e.key !== 'Tab') return;
|
||
// Light focus trap: cycle within the panel (mirrors match-review).
|
||
const panel = document.getElementById('v3-imgpick-panel');
|
||
if (!panel) return;
|
||
// Only trap VISIBLE focusables: hidden tiles (?source=pack 404 →
|
||
// onerror .hidden, unloadable candidates, .hidden buttons) must never
|
||
// catch a Tab. offsetParent is null for display:none / .hidden.
|
||
const foci = Array.from(
|
||
panel.querySelectorAll('button:not(.hidden), input:not(.hidden), [tabindex="0"]'),
|
||
).filter((el) => el.offsetParent !== null);
|
||
if (!foci.length) return;
|
||
const first = foci[0], last = foci[foci.length - 1];
|
||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||
}
|
||
|
||
function close() {
|
||
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } _abort = null; }
|
||
document.getElementById('v3-imgpick-modal')?.classList.add('hidden');
|
||
document.getElementById('v3-imgpick-overlay')?.classList.add('hidden');
|
||
_cur = null;
|
||
_busy = false;
|
||
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { /* */ } }
|
||
_lastFocus = null;
|
||
}
|
||
|
||
// One tile: a 6rem square art/icon face + a caption underneath.
|
||
function tileHtml(attrs, face, label, hidden) {
|
||
return '<button ' + attrs + ' class="group w-24 shrink-0 text-center' + (hidden ? ' hidden' : '') + '">' +
|
||
'<span class="w-24 h-24 rounded-lg overflow-hidden bg-fb-card border border-fb-border/50 hover:border-fb-primary/60 flex items-center justify-center">' + face + '</span>' +
|
||
'<span class="block text-xs text-fb-textDim group-hover:text-fb-text truncate pt-1">' + esc(label) + '</span></button>';
|
||
}
|
||
const imgFace = (src) => '<img src="' + esc(src) + '" alt="" loading="lazy" class="w-full h-full object-cover">';
|
||
const iconFace = (glyph) => '<span class="text-2xl text-fb-textDim">' + glyph + '</span>';
|
||
|
||
const SKELETON_TILE = '<span class="w-24 h-24 rounded-lg bg-fb-card animate-pulse shrink-0"></span>';
|
||
|
||
function render(panel) {
|
||
const fn = _cur.filename;
|
||
// Fresh ?v so a reopened picker never shows a stale "current".
|
||
const curSrc = artBase(fn) + '?v=' + Date.now();
|
||
panel.innerHTML =
|
||
'<div class="flex items-center justify-between gap-3 p-5 pb-3 border-b border-fb-border/40 shrink-0">' +
|
||
'<div class="min-w-0"><h3 class="text-lg font-semibold text-fb-text">Change cover</h3>' +
|
||
'<div class="text-xs text-fb-textDim truncate">' + esc(_cur.title || fn) + '</div></div>' +
|
||
'<button data-ip-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
|
||
|
||
'<div class="p-5 flex flex-col sm:flex-row items-start gap-5 overflow-y-auto v3-scroll">' +
|
||
// Left: the current cover + its provenance.
|
||
'<div class="shrink-0">' +
|
||
'<img data-ip-current src="' + esc(curSrc) + '" alt="" class="w-24 h-24 rounded-lg object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
|
||
'<div class="pt-1 flex items-center gap-1.5">' +
|
||
'<span class="text-xs text-fb-textDim">Current</span>' +
|
||
'<span data-ip-prov class="hidden text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700"></span>' +
|
||
'</div></div>' +
|
||
// Right: the candidate tiles. First row acts instantly; CAA
|
||
// candidates land behind the one /art/candidates fetch.
|
||
'<div class="min-w-0 flex-1 space-y-3">' +
|
||
'<div class="flex flex-wrap gap-3">' +
|
||
tileHtml('data-ip-act="keep"', imgFace(curSrc), 'Current') +
|
||
// Pack tile renders instantly and self-hides when the song ships
|
||
// no art of its own (?source=pack 404s → img onerror); the
|
||
// candidates response reconciles it either way.
|
||
tileHtml('data-ip-act="pack"', imgFace(artBase(fn) + '?source=pack'), 'Pack original') +
|
||
tileHtml('data-ip-act="upload"', iconFace('⤒'), 'Upload') +
|
||
tileHtml('data-ip-act="url"', iconFace('🔗'), 'Paste URL') +
|
||
'</div>' +
|
||
'<div data-ip-caa>' +
|
||
'<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '</div>' +
|
||
'<div class="text-xs text-fb-textDim pt-2">Fetching covers… the source is rate-limited.</div>' +
|
||
'</div>' +
|
||
// Search Cover Art Archive — find an album cover even when the song has
|
||
// no match (the auto candidates above are empty then). Pre-filled from
|
||
// the song's artist + album/title; the source is rate-limited.
|
||
'<div class="space-y-2 pt-1">' +
|
||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Search covers</div>' +
|
||
'<div class="flex gap-2">' +
|
||
'<input data-ip-search-input type="text" value="' + esc(_cur.query || '') + '" placeholder="artist album" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary">' +
|
||
'<button data-ip-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button>' +
|
||
'</div>' +
|
||
'<div data-ip-search-results class="flex flex-wrap gap-3"></div>' +
|
||
'</div>' +
|
||
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
|
||
'</div></div>' +
|
||
'<input type="file" accept="image/*" data-ip-file class="hidden">';
|
||
|
||
wire(panel);
|
||
}
|
||
|
||
function wire(panel) {
|
||
panel.querySelector('[data-ip-close]')?.addEventListener('click', close);
|
||
// The pack tile self-hides when there is no pack art to show.
|
||
const packTile = panel.querySelector('[data-ip-act="pack"]');
|
||
const packImg = packTile ? packTile.querySelector('img') : null;
|
||
if (packImg) packImg.onerror = () => packTile.classList.add('hidden');
|
||
|
||
const file = panel.querySelector('[data-ip-file]');
|
||
file?.addEventListener('change', () => {
|
||
const f = file.files && file.files[0];
|
||
if (!f) return;
|
||
const rd = new FileReader();
|
||
rd.onload = (e) => apply('upload', e.target.result);
|
||
rd.readAsDataURL(f);
|
||
});
|
||
|
||
panel.querySelectorAll('[data-ip-act]').forEach((btn) => {
|
||
btn.addEventListener('click', async () => {
|
||
if (_busy) return;
|
||
const act = btn.getAttribute('data-ip-act');
|
||
if (act === 'keep') { close(); return; }
|
||
if (act === 'pack') { apply('pack'); return; }
|
||
if (act === 'upload') { file?.click(); return; }
|
||
if (act === 'url') {
|
||
// window.prompt is a silent no-op in Electron — use the
|
||
// project's injection-safe async modal; fall back to prompt
|
||
// only if it isn't loaded (mirrors other v3 callers' guard).
|
||
const ask = (typeof window.uiPrompt === 'function')
|
||
? window.uiPrompt({
|
||
title: 'Paste URL',
|
||
label: 'Paste an image link (http or https)',
|
||
okLabel: 'Set cover',
|
||
placeholder: 'https://…',
|
||
})
|
||
: Promise.resolve(window.prompt('Paste an image link (http or https)'));
|
||
const u = String((await ask) || '').trim();
|
||
if (u) apply('url', u);
|
||
}
|
||
});
|
||
});
|
||
const searchInput = panel.querySelector('[data-ip-search-input]');
|
||
const runSearch = () => coverSearch(panel, (searchInput && searchInput.value) || '');
|
||
panel.querySelector('[data-ip-search-go]')?.addEventListener('click', runSearch);
|
||
searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch(); } });
|
||
panel.querySelector('[data-ip-close]')?.focus();
|
||
}
|
||
|
||
// Search Cover Art Archive (via the song-scoped cover-search endpoint) and
|
||
// render the album covers as pickable tiles — the same apply('url') path as
|
||
// the auto candidates. Covers with no CAA art self-hide (img onerror).
|
||
async function coverSearch(panel, query) {
|
||
const out = panel.querySelector('[data-ip-search-results]');
|
||
const fn = _cur && _cur.filename;
|
||
if (!out || !fn) return;
|
||
out.innerHTML = '<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + '</div>';
|
||
let body = null;
|
||
try {
|
||
const r = await fetch('/api/song/' + enc(fn) + '/art/cover-search?q=' + enc(String(query).trim()));
|
||
if (r.ok) body = await r.json();
|
||
} catch (_) { /* falls through to the empty state */ }
|
||
if (!_cur || _cur.filename !== fn) return; // closed / changed song while searching
|
||
const covers = (body && body.covers) || [];
|
||
if (!covers.length) {
|
||
out.innerHTML = '<div class="text-xs text-fb-textDim">' +
|
||
((body && body.error) ? 'Cover search is unavailable right now.' : 'No covers found — try a different search.') +
|
||
'</div>';
|
||
return;
|
||
}
|
||
out.innerHTML = covers.map((c, i) =>
|
||
tileHtml('data-ip-cover="' + i + '"', imgFace(c.thumb_url), c.label || 'Cover')).join('');
|
||
out.querySelectorAll('[data-ip-cover]').forEach((btn) => {
|
||
const img = btn.querySelector('img');
|
||
if (img) img.onerror = () => btn.classList.add('hidden'); // no CAA art for this album → hide
|
||
btn.addEventListener('click', () => {
|
||
if (_busy) return;
|
||
const c = covers[Number(btn.getAttribute('data-ip-cover'))];
|
||
if (c) apply('url', c.thumb_url);
|
||
});
|
||
});
|
||
}
|
||
|
||
// The one candidates fetch, cancelled if the modal closes first. Failure
|
||
// (offline, demo mode, aborted) is silent: the skeletons just clear and
|
||
// the instant tiles remain — never an error wall.
|
||
function loadCandidates(panel) {
|
||
const fn = _cur.filename;
|
||
// Reopening without an intervening close() can leave a prior fetch in
|
||
// flight — cancel it so only the newest request settles the tiles.
|
||
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } }
|
||
_abort = new AbortController();
|
||
fetch('/api/song/' + enc(fn) + '/art/candidates', { signal: _abort.signal })
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((body) => { if (_cur && _cur.filename === fn) patchCandidates(panel, body); })
|
||
.catch(() => { if (_cur && _cur.filename === fn) patchCandidates(panel, null); });
|
||
}
|
||
|
||
function patchCandidates(panel, body) {
|
||
const wrap = panel.querySelector('[data-ip-caa]');
|
||
if (!wrap) return;
|
||
const list = (body && body.candidates) || [];
|
||
// Reconcile the instant tiles with what the server actually knows.
|
||
const cur = list.find((c) => c.kind === 'current');
|
||
const badge = panel.querySelector('[data-ip-prov]');
|
||
if (badge && cur && PROV_LABEL[cur.provenance]) {
|
||
badge.textContent = PROV_LABEL[cur.provenance];
|
||
badge.classList.remove('hidden');
|
||
}
|
||
const packTile = panel.querySelector('[data-ip-act="pack"]');
|
||
if (packTile) packTile.classList.toggle('hidden', !list.some((c) => c.kind === 'pack'));
|
||
|
||
const caa = list.filter((c) => c.kind === 'caa' && c.thumb_url);
|
||
if (!caa.length) { wrap.innerHTML = ''; return; }
|
||
wrap.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim pb-2">Online covers</div>' +
|
||
'<div class="flex flex-wrap gap-3">' +
|
||
caa.map((c, i) => tileHtml(
|
||
'data-ip-cand="' + i + '"',
|
||
imgFace(c.thumb_url),
|
||
c.label || 'Cover')).join('') +
|
||
'</div>';
|
||
wrap.querySelectorAll('[data-ip-cand]').forEach((btn) => {
|
||
// A candidate whose thumb can't load isn't offerable — hide it
|
||
// rather than let a click apply an image nobody saw.
|
||
const img = btn.querySelector('img');
|
||
if (img) img.onerror = () => btn.classList.add('hidden');
|
||
btn.addEventListener('click', () => {
|
||
if (_busy) return;
|
||
const c = caa[Number(btn.getAttribute('data-ip-cand'))];
|
||
if (c) apply('url', c.thumb_url);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Apply a pick through the EXISTING routes; silent on success (close +
|
||
// cache-busted refresh), inline note on failure (the modal stays open so
|
||
// another tile can be tried).
|
||
async function apply(kind, arg) {
|
||
const fn = _cur && _cur.filename;
|
||
if (!fn || _busy) return;
|
||
_busy = true;
|
||
let ok = false;
|
||
try {
|
||
let r = null;
|
||
if (kind === 'url') {
|
||
r = await fetch('/api/song/' + enc(fn) + '/art/url', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url: arg }),
|
||
});
|
||
} else if (kind === 'upload') {
|
||
r = await fetch('/api/song/' + enc(fn) + '/art/upload', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ image: arg }),
|
||
});
|
||
} else if (kind === 'pack') {
|
||
r = await fetch('/api/art/' + enc(fn) + '/override', { method: 'DELETE' });
|
||
}
|
||
if (r && r.ok) {
|
||
// The art routes report soft failures as {error} bodies.
|
||
const body = await r.json().catch(() => ({}));
|
||
ok = !body.error;
|
||
}
|
||
} catch (_) { ok = false; }
|
||
_busy = false;
|
||
if (ok) { close(); refreshArt(fn); return; }
|
||
const status = document.querySelector('#v3-imgpick-panel [data-ip-status]');
|
||
if (status) {
|
||
status.textContent = 'Couldn’t set that cover — try another image.';
|
||
status.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
function openImagePicker(opts) {
|
||
const filename = opts && opts.filename;
|
||
if (!filename) return;
|
||
_lastFocus = document.activeElement;
|
||
const title = (opts && opts.title) || filename;
|
||
const artist = (opts && opts.artist) || '';
|
||
const album = (opts && opts.album) || '';
|
||
// Pre-fill the cover search: "artist album" when the album is known, else
|
||
// just the artist, else the title — the server default backs it up.
|
||
const query = [artist, album].filter(Boolean).join(' ').trim() || title;
|
||
_cur = { filename: filename, title: title, query: query };
|
||
_busy = false;
|
||
const m = ensureModal();
|
||
const panel = document.getElementById('v3-imgpick-panel');
|
||
render(panel);
|
||
m.classList.remove('hidden');
|
||
document.getElementById('v3-imgpick-overlay')?.classList.remove('hidden');
|
||
loadCandidates(panel);
|
||
}
|
||
|
||
window.__fbOpenImagePicker = openImagePicker;
|
||
})();
|