// ==UserScript==
// @name Art Station
// @namespace https://musicbrainz.org/
// @version 2026.6.18
// @description Cover-art editor for MusicBrainz — one gallery to view, group, sort, reorder, retype, comment, remove and download a release's cover art, staged and applied on Enter edit. PoC (discussion #230).
// @author majkinetor
// @match *://*.musicbrainz.org/release/*/cover-art
// @grant none
// @run-at document-start
// ==/UserScript==
//
// Phase-1 PoC. Principle: "you get what you see" — the gallery is the staged
// state; Enter edit makes MB match it. Reads live cover art (CAA JSON + the
// page), no uploads yet (Add/Enter-edit submission land next).
(function () {
'use strict';
const M = location.pathname.match(/\/release\/([0-9a-f-]{36})\/cover-art/i);
if (!M) return;
const MBID = M[1];
// append a node to
/, deferring if neither exists yet (document-start)
function appendEl(el) {
const t = document.head || document.documentElement;
if (t) { t.appendChild(el); return; }
new MutationObserver((_, obs) => { const t2 = document.head || document.documentElement; if (t2) { obs.disconnect(); t2.appendChild(el); } }).observe(document, { childList: true });
}
// Hide the native cover-art UI BEFORE it paints (we run at document-start), so the
// tab never flashes MB's gallery before ours mounts. Our gallery uses .as-* only.
const earlyHide = document.createElement('style');
earlyHide.textContent = '.artwork-cont,#content>h2,#content>p{display:none!important}';
appendEl(earlyHide);
// Proper edit-note attribution, like the other scripts: "Name vX by author - url".
// GM_info is exposed even under @grant none on the common managers; fall back to
// the hard-coded repo URL so the note never reads "v undefined".
const _gm = (typeof GM_info !== 'undefined' && GM_info.script) ? GM_info.script : null;
const SCRIPT_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/tree/main/userscripts/art_station';
const ATTRIBUTION = _gm
? `${_gm.name} v${_gm.version} by ${_gm.author} - ${_gm.homepageURL || _gm.homepage || SCRIPT_URL}`
: `Art Station by majkinetor - ${SCRIPT_URL}`;
const CAA = `https://coverartarchive.org/release/${MBID}`;
const imgUrl = id => `${CAA}/${id}.jpg`; // original
const thumb = (id, n) => `${CAA}/${id}-${n}.jpg`; // 250 / 500 / 1200
// canonical MB cover-art types, in a sensible display order; "(none)" is virtual
const TYPE_ORDER = ['Front', 'Back', 'Booklet', 'Medium', 'Tray', 'Obi', 'Spine', 'Track', 'Liner', 'Sticker', 'Poster', 'Watermark', 'Matrix/Runout', 'Top', 'Bottom', 'Spine', 'Other'];
const ALL_TYPES = ['Front', 'Back', 'Booklet', 'Medium', 'Tray', 'Obi', 'Spine', 'Track', 'Liner', 'Sticker', 'Poster', 'Watermark', 'Raw/Unedited', 'Matrix/Runout', 'Top', 'Bottom', 'Panel', 'Other'];
const NO_TYPE = '(no type)';
let MODEL = []; // [{ id, types:[], comment, order, w, h, bytes, _del, _new, _file }]
const SIZES = new Map(); // CAA image id -> original file size in bytes (from archive.org metadata)
const fmtSize = b => b >= 1048576 ? (b / 1048576).toFixed(1) + 'Mb' : Math.max(1, Math.round(b / 1024)) + 'Kb';
// footer line under the image: "1.2Mb 600 × 600" — size first, then resolution,
// each half shown once known, separated by a wide gap (em-space).
function dimText(it) {
if (it._new) return 'local';
const parts = [];
if (it.bytes) parts.push(fmtSize(it.bytes));
if (it.w && it.h) parts.push(`${it.w} × ${it.h}`);
return parts.length ? parts.join(' ') : '…';
}
function refreshDim(it) {
const el = document.querySelector(`.as-card[data-id="${CSS.escape(String(it.id))}"] .as-dim`);
if (el) el.textContent = dimText(it);
}
// one request per release: archive.org item metadata carries every original's byte size
async function loadSizes() {
try {
const j = await fetch(`https://archive.org/metadata/mbid-${MBID}`, { headers: { Accept: 'application/json' } }).then(r => r.ok ? r.json() : null);
if (!j || !j.files) return;
for (const f of j.files) {
if (f.source !== 'original' || !f.size) continue;
const m = String(f.name).match(/-(\d+)\.[a-z0-9]+$/i);
if (m) SIZES.set(m[1], +f.size);
}
MODEL.forEach(it => { const b = SIZES.get(String(it.id)); if (b) { it.bytes = b; refreshDim(it); } });
} catch (e) { /* size is a nicety — never block the gallery */ }
}
let SETTINGS = load();
function load() { try { return Object.assign({ tile: 200, group: false, sort: 'type', detailed: false }, JSON.parse(localStorage.getItem('artstation:settings') || '{}')); } catch (e) { return { tile: 200, group: false, sort: 'type', detailed: false }; } }
function save() { try { localStorage.setItem('artstation:settings', JSON.stringify(SETTINGS)); } catch (e) {} }
// ── data ───────────────────────────────────────────────────────────────────
// MB's page (its DB) is the source of truth for the cover list — it includes images
// that aren't on the Cover Art Archive yet (just added). CAA only enriches comments.
function parsePageArt() {
const blocks = [...document.querySelectorAll('.artwork-cont')];
if (!blocks.length) return null;
return blocks.map((b, i) => {
const ed = b.querySelector('a[href*="/edit-cover-art/"]');
const m = ed && ed.getAttribute('href').match(/\/edit-cover-art\/(\d+)/);
if (!m) return null;
// each piece is its own
— parse types from the "Types:"
ONLY (the comment
// is a separate
, so reading the whole block grabbed it, e.g. "Types: -test")
const ps = [...b.querySelectorAll('p')];
const typeP = ps.find(p => /^\s*Types:/.test(p.textContent));
const raw = typeP ? typeP.textContent.replace(/^\s*Types:\s*/, '').trim() : '';
const types = (raw && raw !== '-') ? raw.split(',').map(s => s.trim()).filter(s => s && s !== '-') : [];
const cmtP = ps.find(p => p !== typeP && p.textContent.trim() && !/All sizes:/i.test(p.textContent) && !/Dimensions:/i.test(p.textContent));
const comment = cmtP ? cmtP.textContent.trim() : '';
const orig = [...b.querySelectorAll('a')].find(a => a.textContent.trim().toLowerCase() === 'original');
const img = orig ? new URL(orig.getAttribute('href'), location.href).href : '';
const pdf = /\.pdf(\?|$)/i.test(img);
return { id: m[1], types, comment, pending: b.classList.contains('mp'), pdf, img, order: i };
}).filter(Boolean);
}
async function loadArt() {
const pageArt = parsePageArt();
let caa = [];
try { const j = await fetch(CAA, { headers: { Accept: 'application/json' } }).then(r => r.ok ? r.json() : null); if (j) caa = j.images || []; }
catch (e) { /* not propagated / none yet */ }
const byId = new Map(caa.map(im => [String(im.id), im]));
const source = (pageArt && pageArt.length)
? pageArt.map(p => ({ id: p.id, types: p.types, comment: p.comment || (byId.get(String(p.id)) || {}).comment || '', pending: p.pending, img: p.img || (byId.get(String(p.id)) || {}).image || imgUrl(p.id), pdf: p.pdf }))
: caa.map(im => ({ id: im.id, types: (im.types || []).slice(), comment: im.comment || '', pending: false, img: im.image || imgUrl(im.id), pdf: /\.pdf(\?|$)/i.test(im.image || '') }));
MODEL = source.map((s, i) => ({
id: s.id, types: s.types.slice(), comment: s.comment,
order: i, w: 0, h: 0, _del: false, _new: false, _pending: !!s.pending, _pdf: !!s.pdf || /\.pdf(\?|$)/i.test(s.img || ''), _img: s.img,
_origTypes: s.types.slice(), _origComment: s.comment, _origOrder: i,
}));
render();
MODEL.forEach(measure); // lazy-fill dimensions
loadSizes(); // lazy-fill file sizes (single archive.org request)
}
function measure(it) {
if (it._new || it.w) return;
const img = new Image();
img.onload = () => { it.w = img.naturalWidth; it.h = img.naturalHeight; refreshDim(it); };
img.src = imgUrl(it.id);
}
const changed = it => it._del || it._new || it.comment !== it._origComment || it.order !== it._origOrder || it.types.join('|') !== it._origTypes.join('|');
const stagedCount = () => MODEL.filter(changed).length;
const selectable = () => MODEL.filter(it => !it._del);
const allSelected = () => { const s = selectable(); return s.length > 0 && s.every(it => it._sel); };
// reorder (drag) only in the canonical Position view — ungrouped + sorted by position.
// Grouping is view-only; other sorts don't map to the committed order.
const canReorder = () => !SETTINGS.group && !SETTINGS.detailed && SETTINGS.sort === 'type';
// ── render ───────────────────────────────────────────────────────────────────
const root = document.createElement('div'); root.id = 'as-root';
let _mounted = false;
let _showOrig = false; // "Show original" (View) — reveal MB's native UI, keep only our toolbar
const _native = []; // the native cover-art elements mount() hid, so we can show them again
function mount() {
if (_mounted) return; _mounted = true;
const anchor = document.querySelector('#content') || document.body;
// #230: sit BELOW the MB header + the entity tabs. ul.tabs is nested in a
// div.tabs child of #content, so climb to that #content-level ancestor.
const childOf = (el) => { if (!el) return null; let n = el; while (n.parentElement && n.parentElement !== anchor) n = n.parentElement; return n.parentElement === anchor ? n : null; };
const afterTabs = childOf(anchor.querySelector('ul.tabs'));
const afterH1 = childOf(anchor.querySelector('h1'));
if (afterTabs) afterTabs.insertAdjacentElement('afterend', root);
else if (afterH1) afterH1.insertAdjacentElement('afterend', root);
else anchor.insertBefore(root, anchor.firstChild);
// hide the native cover-art UI between the tabs and the page footer: the type
//
s, the .artwork-cont blocks and the trailing "These images…" note.
const hide = el => { el.style.display = 'none'; _native.push(el); };
[...anchor.children].forEach(ch => {
if (ch === root || ch === afterTabs || ch === afterH1) return;
if (ch.tagName === 'H2' || ch.tagName === 'P') hide(ch);
else if (ch.querySelector && ch.querySelector('.artwork-cont')) hide(ch);
else if (ch.classList && ch.classList.contains('artwork-cont')) hide(ch);
});
document.querySelectorAll('.artwork-cont').forEach(hide);
}
// "Show original" (View): un-hide MB's native cover-art UI and collapse our
// gallery to just the toolbar — like Apollo's native/script switcher. #234
function applyOriginal() {
earlyHide.disabled = _showOrig; // the document-start hiding style
_native.forEach(el => { el.style.display = _showOrig ? '' : 'none'; });
root.classList.toggle('as-orig', _showOrig); // hides the whole Art Station UI
ensureSwitch();
}
// #234: an Apollo-style fixed switcher (bottom-right) toggling Original ⇄ Art
// Station — always visible; the only control left when the native UI is shown.
function ensureSwitch() {
let sw = document.getElementById('as-switch');
if (!sw) {
sw = document.createElement('button'); sw.id = 'as-switch';
document.body.appendChild(sw);
sw.onclick = () => { _showOrig = !_showOrig; render(); };
}
sw.textContent = _showOrig ? 'Art Station' : 'Original';
sw.title = _showOrig ? 'Switch back to the Art Station gallery' : 'Show the original MusicBrainz cover-art page';
}
// at big tile sizes the selection outline alone is plenty obvious, so drop the
// per-card ✓ badge — keeps large artwork uncluttered. #234
function applyZoomClass() { root.classList.toggle('as-zoomed', SETTINGS.tile >= 280); }
function render() {
mount();
const y = window.scrollY; // keep the viewport put — rebuilding innerHTML must not jump the page
const n = opsCount();
const groups = grouped();
// #238 Detailed view: a flat list, image left + all type checkboxes & the full
// comment beside it (read long comments / see every type without the popover).
const body = SETTINGS.detailed
? `
`
: SETTINGS.group
? groups.map(g => groupRow(g.type, g.items)).join('') // compact: label column + cards beside it
: groups.map(g => section(g.type, g.items)).join('');
root.innerHTML = bar(n) + commentPresets() + dropZone() + newSection() + body + deletedSection();
wire();
applyOriginal(); // keep the native/script view state across re-renders
applyZoomClass();
fitTypePills(); // show as many types as the pill width allows
fitToolbar(); // icon-only buttons if the toolbar would otherwise wrap
if (window.scrollY !== y) window.scrollTo(0, y);
}
// #234: a grid card's type pill shows as many of its types as fit on one line;
// a trailing "+" appears only when some types are hidden for lack of space.
function fitTypePills() {
root.querySelectorAll('.as-foot-type .as-type').forEach(pill => {
if (pill.classList.contains('as-type-add')) return; // untyped placeholder
const it = byId(cardId(pill)); if (!it || !it.types.length) return;
const types = it.types;
pill.textContent = types.join(', ');
let n = types.length;
while (n > 1 && pill.scrollWidth > pill.clientWidth) { n--; pill.textContent = types.slice(0, n).join(', ') + ' +'; }
});
}
// shared autocomplete of the comments already used on this release (#238 presets)
function commentPresets() {
const seen = [...new Set(MODEL.map(it => (it.comment || '').trim()).filter(Boolean))];
return ``;
}
function grouped() {
if (!SETTINGS.group) {
// Position view (committed order): new uploads sit INLINE, positioned among covers
const items = MODEL.filter(it => !it._del).slice().sort(sortFn);
return [{ type: null, items }];
}
// group mode is view-only; new uploads get their own section on top (see newSection)
let items = MODEL.filter(it => !it._del && !it._new);
// group by primary type; untyped → NO_TYPE; order groups by TYPE_ORDER then alpha
const map = new Map();
for (const it of items) { const t = (it.types[0] || NO_TYPE); if (!map.has(t)) map.set(t, []); map.get(t).push(it); }
const keys = [...map.keys()].sort((a, b) => {
const ia = TYPE_ORDER.indexOf(a), ib = TYPE_ORDER.indexOf(b);
if (a === NO_TYPE) return 1; if (b === NO_TYPE) return -1;
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
});
for (const k of keys) map.get(k).sort(sortFn);
return keys.map(k => ({ type: k, items: map.get(k) }));
}
const typeRank = t => { const i = TYPE_ORDER.indexOf(t); return i < 0 ? 99 : i; };
function sortFn(a, b) {
if (SETTINGS.sort === 'bytype') return typeRank(a.types[0] || '') - typeRank(b.types[0] || '') || a.order - b.order;
if (SETTINGS.sort === 'dim') return (b.w * b.h) - (a.w * a.h) || a.order - b.order;
if (SETTINGS.sort === 'newest') return b.id - a.id; // CAA id desc ≈ upload recency (no real date in CAA)
return a.order - b.order; // position (committed order)
}
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
function bar(n) {
return `
Size
${!canReorder() ? '⚠' : ''}
${selBox()}
`;
}
const commitInner = n => `✓Enter edit${n ? ` (${n})` : ''}`;
// #234: the selection cluster lives in the center of the main toolbar (the old
// bottom bulk bar is gone). syncSel() rebuilds just this span in place so
// right-click paint-select never reflows the grid.
function selBox() {
const sel = MODEL.filter(it => it._sel && !it._del);
return `${sel.length ? `${sel.length} selected` : 'none selected'}
${sel.length ? `
` : ''}`;
}
function section(type, items) {
const label = type === null ? 'All covers' : type;
return `
${esc(label)}
${items.length}
${items.map(card).join('')}
`;
}
// compact group: type label in a left column, cards flow beside it (no full-width waste)
function groupRow(type, items) {
const label = type === NO_TYPE ? 'No type' : type;
return `
${esc(label)}${items.length}
${items.map(card).join('')}
`;
}
function dropZone() {
if (!_dropZone) return '';
return `
⬇ Drop cover images hereor click to browse · new covers go first
`;
}
function newSection() {
if (!SETTINGS.group) return ''; // Position view shows new uploads inline, positioned among covers
const news = MODEL.filter(it => it._new && !it._del).sort((a, b) => a.order - b.order);
if (!news.length) return '';
return `
New uploads
${news.length}
${news.map(card).join('')}
`;
}
function deletedSection() {
const dels = MODEL.filter(it => it._del);
if (!dels.length) return '';
return `
`;
}
// #234: footer below the image (mockup-driven). Row 1 = the comment on the
// left + "dimensions · size" on the right — sharing one row means an empty
// comment costs no extra height. Row 2 = the type as a centered pill on a
// divider line at the card's bottom (first type only, "+" when there are
// more). Empty comment → a hover-only ✎; untyped → a faint + pill.
function foot(it) {
const firstType = it.types[0] || '';
// seed with the full list; fitTypePills() trims to what fits and adds "+" if needed
const typePill = firstType
? `${esc(it.types.join(', '))}`
: `+ type`;
const typeRow = `
${typePill}
`;
const dim = `${esc(dimText(it))}`;
if (it._del) return `
`;
}
// #238 Detailed view row: image + id on the left, all type checkboxes and the
// full comment field beside it. No per-row toolbar actions (selection / delete
// live on the main toolbar).
function detailRow(it) {
const src = it._new ? it._file : thumb(it.id, 250);
const types = ALL_TYPES.map(t => ``).join('');
return `
${it._new ? 'NEW' : ''}${it._pdf ? 'PDF' : ''}
${esc(dimText(it))}
${it._new ? '' : `
#${esc(it.id)}
`}
Types
${types}
`;
}
// ── interaction ───────────────────────────────────────────────────────────────
function byId(id) { return MODEL.find(it => String(it.id) === String(id)); }
function cardId(el) { const c = el.closest('.as-card'); return c ? c.dataset.id : null; }
// Wire the comment controls. #234 split the footer (pencil on row 1, comment
// on row 2 which only exists when there's a comment), so entering/leaving edit
// re-renders — render() keeps the viewport put, so the page still doesn't jump.
function wireComments() {
root.querySelectorAll('.as-pencil, .as-cmt-text').forEach(el => el.onclick = e => {
e.stopPropagation(); const it = byId(cardId(el)); if (!it) return;
it._editcmt = true; render();
root.querySelector(`.as-card[data-id="${CSS.escape(String(it.id))}"] .as-cmt`)?.focus();
});
root.querySelectorAll('.as-cmt').forEach(inp => {
inp.oninput = () => { const it = byId(cardId(inp)); if (it) { it.comment = inp.value; refreshStaged(); } };
inp.onblur = () => { const it = byId(cardId(inp)); if (it) { it._editcmt = false; render(); } };
// Enter saves and jumps to the NEXT card's comment (Escape just bails out).
inp.onkeydown = e => {
if (e.key === 'Escape') { e.preventDefault(); inp.blur(); return; }
if (e.key !== 'Enter') return;
e.preventDefault();
const it = byId(cardId(inp)); if (!it) return;
it.comment = inp.value; it._editcmt = false;
inp.onblur = null; // we drive the transition — don't let the stale blur double-render
const cards = [...root.querySelectorAll('.as-card:not(.del)')];
const idx = cards.findIndex(c => c.dataset.id === String(it.id));
const nextIt = (idx >= 0 && cards[idx + 1]) ? byId(cards[idx + 1].dataset.id) : null;
if (nextIt) nextIt._editcmt = true;
refreshStaged(); render();
if (nextIt) root.querySelector(`.as-card[data-id="${CSS.escape(String(nextIt.id))}"] .as-cmt`)?.focus();
};
});
}
// #238 wire the detailed-view rows: thumbnail → lightbox, inline type checkboxes,
// and the comment field — all editing the model in place (no re-render, no jump).
function wireDetail() {
root.querySelectorAll('.as-drow').forEach(row => {
const it = byId(row.dataset.id); if (!it) return;
const th = row.querySelector('.as-dthumb');
if (th) {
th.onclick = e => { if (e.target.closest('button')) return; if (it._pdf) window.open(it._img, '_blank', 'noopener'); else openLightbox(it.id); };
const img = th.querySelector('img');
if (img) {
img.onerror = () => { const orig = !it._pdf ? (it._img || imgUrl(it.id)) : null; if (orig && img.getAttribute('src') !== orig) img.src = orig; else th.classList.add('na'); };
if (img.complete && !img.naturalWidth && img.getAttribute('src')) img.onerror();
}
}
row.querySelectorAll('.as-dtypes input').forEach(cb => cb.onchange = () => {
it.types = ALL_TYPES.filter(t => row.querySelector(`.as-dtypes input[value="${CSS.escape(t)}"]`).checked);
refreshStaged();
});
const cmt = row.querySelector('.as-dcmt');
if (cmt) cmt.oninput = () => { it.comment = cmt.value; refreshStaged(); };
// selection: the checkbox is the certain indicator; right-click also paints
const sel = row.querySelector('.as-dsel');
if (sel) sel.onchange = () => { it._sel = sel.checked; row.classList.toggle('sel', it._sel); syncSel(); };
row.onmousedown = e => { if (e.button !== 2) return; e.preventDefault(); _paint = { value: !it._sel }; paintCard(row); };
});
}
function wire() {
root.querySelector('.as-size').oninput = e => { SETTINGS.tile = +e.target.value; document.documentElement.style.setProperty('--as-tile', SETTINGS.tile + 'px'); applyZoomClass(); fitTypePills(); };
root.querySelector('.as-size').onchange = () => { save(); render(); };
const view = root.querySelector('.as-view'); if (view) view.onclick = e => { e.stopPropagation(); openViewPop(view); };
const dw = root.querySelector('.as-dragwarn'); if (dw) dw.onclick = () => { SETTINGS.detailed = false; SETTINGS.group = false; SETTINGS.sort = 'type'; save(); render(); };
root.querySelector('.as-add').onclick = toggleDropZone;
const commit = root.querySelector('.as-commit'); if (commit && !commit.disabled) commit.onclick = enterEdit;
root.querySelectorAll('.as-undo').forEach(b => b.onclick = e => { e.stopPropagation(); const it = byId(cardId(e.target)); if (it) { it._del = false; render(); } });
wireComments();
wireDetail();
const dz = root.querySelector('.as-dropzone');
if (dz) {
dz.onclick = pickFiles;
dz.ondragover = e => { e.preventDefault(); dz.classList.add('over'); };
dz.ondragleave = () => dz.classList.remove('over');
dz.ondrop = e => { e.preventDefault(); dz.classList.remove('over'); addFiles(e.dataTransfer.files); };
}
// type pill → popover
root.querySelectorAll('.as-type').forEach(ch => ch.onclick = e => { e.stopPropagation(); openTypePop(ch); });
// click the THUMB (not just the , which is display:none on a not-yet-propagated
// cover) → lightbox; PDFs open in a new tab. right-click card → toggle selection
root.querySelectorAll('.as-thumb').forEach(th => {
th.onclick = e => { if (e.target.closest('button')) return; const it = byId(cardId(e.target)); if (!it) return; if (it._pdf) window.open(it._img, '_blank', 'noopener'); else openLightbox(it.id); };
const img = th.querySelector('img'); if (!img) return;
// A freshly-added cover has its original uploaded but the CAA thumbnails
// (250/500) aren't generated yet — so the thumb URL 404s and native MB
// shows a placeholder. We can do better: fall back to the full original
// (the same URL the lightbox uses), so the image shows in the gallery.
// Only show the "not on CAA" placeholder if the original 404s too. PDFs
// can't render as , so they keep the placeholder.
img.onerror = () => {
const it = byId(cardId(img));
const orig = it && !it._pdf ? (it._img || imgUrl(it.id)) : null;
if (orig && img.getAttribute('src') !== orig) img.src = orig;
else th.classList.add('na');
};
if (img.complete && !img.naturalWidth && img.getAttribute('src')) img.onerror();
});
// right-button paint-select IN PLACE — no render(), so the page never jumps.
// down toggles the start card; holding right + moving paints the same state on hovered cards.
root.querySelectorAll('.as-card').forEach(c => {
c.onmousedown = e => {
if (e.button !== 2 || c.classList.contains('del')) return;
e.preventDefault(); const it = byId(c.dataset.id); if (!it) return;
_paint = { value: !it._sel }; paintCard(c);
};
});
wireSel();
wireDrag();
markCursor();
}
function wireSel() {
const q = s => root.querySelector(s);
q('.as-selall') && (q('.as-selall').onclick = () => { selectable().forEach(it => it._sel = true); root.querySelectorAll('.as-card:not(.del), .as-drow').forEach(c => c.classList.add('sel')); root.querySelectorAll('.as-dsel').forEach(cb => cb.checked = true); syncSel(); });
q('.as-selclr') && (q('.as-selclr').onclick = () => { MODEL.forEach(it => it._sel = false); root.querySelectorAll('.as-card.sel, .as-drow.sel').forEach(c => c.classList.remove('sel')); root.querySelectorAll('.as-dsel').forEach(cb => cb.checked = false); syncSel(); });
q('.as-bk-rm') && (q('.as-bk-rm').onclick = () => { MODEL.forEach(it => { if (it._sel) { it._del = true; it._sel = false; } }); render(); });
q('.as-bk-dl') && (q('.as-bk-dl').onclick = () => MODEL.filter(it => it._sel && !it._new).forEach((it, i) => setTimeout(() => dlOne(it), i * 350)));
q('.as-bk-type') && (q('.as-bk-type').onclick = e => { e.stopPropagation(); openBulkTypePop(q('.as-bk-type')); });
q('.as-bk-cmt') && (q('.as-bk-cmt').onclick = e => { e.stopPropagation(); openBulkCommentPop(q('.as-bk-cmt')); });
q('.as-bk-report') && (q('.as-bk-report').onclick = e => { e.stopPropagation(); openReport(); });
}
// right-button paint selection (held + move)
let _paint = null;
function paintCard(c) {
if (!c || !_paint || c.classList.contains('del')) return;
const it = byId(c.dataset.id); if (!it || it._sel === _paint.value) return;
it._sel = _paint.value; c.classList.toggle('sel', it._sel);
const cb = c.querySelector('.as-dsel'); if (cb) cb.checked = it._sel; // keep the row checkbox in sync
syncSel();
}
document.addEventListener('mousemove', e => {
if (!_paint || !e.buttons) return; // e.buttons falls to 0 if the button was released off-window
const c = e.target.closest && e.target.closest('.as-card, .as-drow');
if (c && root.contains(c)) paintCard(c);
});
document.addEventListener('mouseup', () => { _paint = null; });
window.addEventListener('resize', () => { if (root.isConnected) fitToolbar(); });
// right-click is our selection gesture across the gallery — suppress the native menu there
document.addEventListener('contextmenu', e => { if (root.contains(e.target)) e.preventDefault(); });
// refresh just the toolbar's selection cluster in place — no grid reflow, so
// right-click paint-select never makes the page jump.
function syncSel() {
const box = root.querySelector('.as-selbox');
if (box) { box.innerHTML = selBox(); wireSel(); fitToolbar(); }
}
function refreshStaged() {
const n = opsCount(); const c = root.querySelector('.as-commit');
if (c) { c.innerHTML = commitInner(n); c.disabled = !n; if (!c.disabled) c.onclick = enterEdit; fitToolbar(); }
}
// #234: when the toolbar's real items + gaps can't fit one row (the flex
// spacers would have to collapse and it'd wrap), collapse the labelled buttons
// to icon-only — the icons + tooltips carry the meaning. Measured by summing
// widths (the flex:1 spacers defeat scrollWidth/offsetTop-based detection).
function fitToolbar() {
const bar = root.querySelector('.as-bar'); if (!bar) return;
bar.classList.remove('as-compact'); // measure at full labels
const kids = [...bar.children];
let need = 11 * Math.max(0, kids.length - 1); // inter-item gaps
kids.forEach(el => { if (!el.classList.contains('as-sp')) need += el.offsetWidth; });
bar.classList.toggle('as-compact', need > bar.clientWidth - 24); // minus h-padding
}
// the list of pending MB operations behind "N staged changes"
function pendingOps() {
const label = it => it.types[0] || (it._new ? 'new image' : 'cover');
const ops = [];
MODEL.filter(it => it._new && !it._del).forEach(it => ops.push(`➕ Add ${label(it)}${it.types.length ? ` — ${it.types.join(', ')}` : ''}${it.comment ? ` “${it.comment}”` : ''}`));
MODEL.filter(it => it._del && !it._new).forEach(it => ops.push(`🗑 Remove ${label(it)}`));
MODEL.filter(it => !it._del && !it._new).forEach(it => {
if (it.types.join('|') !== it._origTypes.join('|')) ops.push(`🏷 Set type on ${it._origTypes[0] || 'cover'} → ${it.types.join(', ') || '(none)'}`);
if (it.comment !== it._origComment) ops.push(`✎ Comment on ${label(it)} → ${it.comment ? `“${it.comment}”` : '(cleared)'}`);
});
// reorder = the EXISTING covers' relative order changed. Inserting new covers
// shifts indices but is positioned by the add op itself (not a separate reorder).
const ex = MODEL.filter(it => !it._del && !it._new);
const now = ex.slice().sort((a, b) => a.order - b.order).map(it => it.id).join(',');
const orig = ex.slice().sort((a, b) => a._origOrder - b._origOrder).map(it => it.id).join(',');
if (now !== orig) ops.push('↕ Reorder covers');
return ops;
}
// the count shown on "Enter edit (N)" = the number of real MB edits we'll submit
// (buildPlan merges a cover's type+comment change into one edit), so it matches
// the panel's operation list exactly. #234
const opsCount = () => buildPlan().length;
// #234: the "View ▾" dropdown — Sort options + Group toggle, moved off the
// main toolbar to free its center for the selection controls.
function openViewPop(btn) {
document.querySelectorAll('.as-pop').forEach(p => p.remove());
const pop = document.createElement('div'); pop.className = 'as-pop as-view-pop';
const sorts = [['type', 'Position'], ['bytype', 'Type'], ['dim', 'Dimensions'], ['newest', 'Newest']];
const vmode = SETTINGS.detailed ? 'detailed' : SETTINGS.group ? 'group' : 'grid';
pop.innerHTML = `
Sort
`
+ sorts.map(([v, l]) => ``).join('')
+ `
View
`
+ [['grid', 'Grid', ''], ['detailed', 'Detailed view', '(list + all types & comment)'], ['group', 'Group by type', '(view-only)']]
.map(([v, l, note]) => ``).join('');
document.body.appendChild(pop);
placePop(pop, btn.getBoundingClientRect());
pop.querySelectorAll('input[name="as-vsort"]').forEach(r => r.onchange = () => { SETTINGS.sort = r.value; save(); render(); });
// #234: the view modes are mutually exclusive — Grid / Detailed / Group.
pop.querySelectorAll('input[name="as-vmode"]').forEach(r => r.onchange = () => {
SETTINGS.detailed = r.value === 'detailed'; SETTINGS.group = r.value === 'group';
save(); render();
});
const off = e => { if (!pop.contains(e.target) && e.target !== btn) { pop.remove(); document.removeEventListener('mousedown', off); } };
setTimeout(() => document.addEventListener('mousedown', off), 0);
}
// position a popover next to an anchor, flipping up / clamping so it stays on-screen
function placePop(pop, r) {
const ph = pop.offsetHeight, pw = pop.offsetWidth, vh = innerHeight, vw = innerWidth, M = 8;
let top = r.bottom + 3;
if (top + ph > vh - M && r.top - ph - 3 >= M) top = r.top - ph - 3; // flip above the anchor
top = Math.max(M, Math.min(top, vh - ph - M));
let left = Math.max(M, Math.min(r.left, vw - pw - M));
pop.style.top = (top + scrollY) + 'px';
pop.style.left = (left + scrollX) + 'px';
}
function openTypePop(chip) {
document.querySelectorAll('.as-pop').forEach(p => p.remove());
const it = byId(cardId(chip)); if (!it) return;
const pop = document.createElement('div'); pop.className = 'as-pop';
pop.innerHTML = `