/* ========================================================================== Sprite Forge — application logic Vanilla JS, no build step (this is a Flask-served static app). Talks to the /api/* endpoints in app.py. State lives in one `state` object; every mutation goes through a small set of render*() functions so the DOM never drifts out of sync with what the server actually has. ========================================================================== */ const API = { async uploadImages(files, padding) { const fd = new FormData(); for (const f of files) fd.append('files', f); fd.append('padding', padding); return fetchJSON('/api/upload/images', { method: 'POST', body: fd }); }, async uploadZip(file, padding) { const fd = new FormData(); fd.append('file', file); fd.append('padding', padding); return fetchJSON('/api/upload/zip', { method: 'POST', body: fd }); }, async listSheets() { return fetchJSON('/api/sheets'); }, async listSprites(params = {}) { const qs = new URLSearchParams(params).toString(); return fetchJSON(`/api/sprites${qs ? '?' + qs : ''}`); }, async getSprite(id) { return fetchJSON(`/api/sprite/${id}`); }, async acceptSprite(id) { return fetchJSON(`/api/sprite/${id}/accept`, { method: 'POST' }); }, async rejectSprite(id) { return fetchJSON(`/api/sprite/${id}/reject`, { method: 'POST' }); }, async updateMetadata(id, payload) { return fetchJSON(`/api/sprite/${id}/metadata`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); }, async editBbox(id, bbox) { return fetchJSON(`/api/sprite/${id}/edit-bbox`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(bbox), }); }, async mergeSprites(ids) { return fetchJSON('/api/sprites/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sprite_ids: ids }), }); }, async splitSprite(id, boxes) { return fetchJSON('/api/sprites/split', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sprite_id: id, boxes }), }); }, async batchAcceptAbove(threshold) { return fetchJSON('/api/batch/accept-above', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threshold }), }); }, async batchRejectDuplicates() { return fetchJSON('/api/batch/reject-duplicates', { method: 'POST' }); }, async batchDedupeKeepOne() { return fetchJSON('/api/batch/dedupe-keep-one', { method: 'POST' }); }, async batchAssign(payload) { return fetchJSON('/api/batch/assign', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); }, async animationFrames(sheetId) { return fetchJSON(`/api/animation/frames?sheet_id=${encodeURIComponent(sheetId)}`); }, async datasetBalance() { return fetchJSON('/api/dataset/balance'); }, async datasetCalibration() { return fetchJSON('/api/dataset/calibration'); }, async datasetSplitPreview() { return fetchJSON('/api/dataset/split-preview'); }, async batchLowConfidence() { return fetchJSON('/api/batch/low-confidence'); }, async datasetSummary() { return fetchJSON('/api/dataset/summary'); }, async datasetUpload() { return fetchJSON('/api/dataset/upload', { method: 'POST' }); }, async getCategories() { return fetchJSON('/api/categories'); }, async addCategory(name, kind) { return fetchJSON('/api/categories', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, kind }), }); }, }; async function fetchJSON(url, opts) { const res = await fetch(url, opts); let body; try { body = await res.json(); } catch (e) { body = { error: `Server returned ${res.status}` }; } if (!res.ok) { const err = new Error(body.error || `Request failed (${res.status})`); err.status = res.status; throw err; } return body; } /* ========================================================================== State ========================================================================== */ const state = { sheets: [], activeSheetId: null, spritesBySheet: {}, // sheet_id -> [sprite, ...] (all statuses, for the canvas) selectedSpriteId: null, padding: 0, zoom: 1, categories: [], styles: [], mergeSelection: [], // sprite ids picked for a merge, in canvas click order mode: 'browse', // 'browse' | 'merge-pick' | 'split-pick' }; function currentSprites() { return state.spritesBySheet[state.activeSheetId] || []; } function findSprite(id) { return currentSprites().find(s => s.id === id) || null; } /* ========================================================================== Toasts ========================================================================== */ function toast(message, isError = false) { const stack = document.getElementById('toast-stack'); const el = document.createElement('div'); el.className = 'toast' + (isError ? ' error' : ''); el.textContent = message; stack.appendChild(el); setTimeout(() => el.remove(), 4200); } function reportError(err) { console.error(err); toast(err.message || 'Something went wrong', true); } /* ========================================================================== Upload flow ========================================================================== */ function setupUpload() { const imagesInput = document.getElementById('file-input-images'); const zipInput = document.getElementById('file-input-zip'); document.getElementById('btn-upload-images').addEventListener('click', () => imagesInput.click()); document.getElementById('btn-upload-zip').addEventListener('click', () => zipInput.click()); imagesInput.addEventListener('change', async (e) => { if (e.target.files.length) await handleImageUpload(Array.from(e.target.files)); e.target.value = ''; }); zipInput.addEventListener('change', async (e) => { if (e.target.files.length) await handleZipUpload(e.target.files[0]); e.target.value = ''; }); const dropzone = document.getElementById('dropzone'); dropzone.addEventListener('click', () => imagesInput.click()); ['dragenter', 'dragover'].forEach(evt => dropzone.addEventListener(evt, (e) => { e.preventDefault(); dropzone.classList.add('drag-over'); }) ); ['dragleave', 'drop'].forEach(evt => dropzone.addEventListener(evt, (e) => { e.preventDefault(); dropzone.classList.remove('drag-over'); }) ); dropzone.addEventListener('drop', async (e) => { const files = Array.from(e.dataTransfer.files || []); if (!files.length) return; const zip = files.find(f => f.name.toLowerCase().endsWith('.zip')); if (zip && files.length === 1) { await handleZipUpload(zip); } else { await handleImageUpload(files.filter(f => !f.name.toLowerCase().endsWith('.zip'))); } }); document.querySelectorAll('.padding-chip').forEach(chip => { chip.addEventListener('click', () => { document.querySelectorAll('.padding-chip').forEach(c => c.classList.remove('active')); chip.classList.add('active'); state.padding = parseInt(chip.dataset.padding, 10); }); }); } async function handleImageUpload(files) { const dropzone = document.getElementById('dropzone'); const original = dropzone.innerHTML; dropzone.innerHTML = `
···
Processing ${files.length} file(s)…`; try { const result = await API.uploadImages(files, state.padding); reportUploadResult(result); await refreshSheets(); } catch (err) { reportError(err); } finally { dropzone.innerHTML = original; } } async function handleZipUpload(file) { const dropzone = document.getElementById('dropzone'); const original = dropzone.innerHTML; dropzone.innerHTML = `
···
Extracting ${file.name}…`; try { const result = await API.uploadZip(file, state.padding); reportUploadResult(result); if (result.skipped && result.skipped.length) { toast(`Skipped ${result.skipped.length} file(s) in the archive (unsupported or unsafe)`, false); } await refreshSheets(); } catch (err) { reportError(err); } finally { dropzone.innerHTML = original; } } function reportUploadResult(result) { const errored = (result.sheets || []).filter(s => s.error); if (errored.length) { toast(`${errored.length} file(s) could not be processed`, true); } toast(`Detected ${result.total_sprites} sprite(s) across ${result.total_sheets} sheet(s)`); } /* ========================================================================== Sheets list + canvas rendering ========================================================================== */ async function refreshSheets() { const { sheets } = await API.listSheets(); state.sheets = sheets; renderSheetList(); if (!state.activeSheetId && sheets.length) { await selectSheet(sheets[0].id); } else if (state.activeSheetId) { // Keep the active sheet's sprite counts fresh (e.g. after an upload // added sprites to it, or a merge/split changed its count). await selectSheet(state.activeSheetId, /*keepSelection=*/true); } await refreshSummary(); } function renderSheetList() { const container = document.getElementById('sheet-list'); if (!state.sheets.length) { container.innerHTML = '

Nothing uploaded yet. A sheet can be a single sprite or a full sheet with hundreds — either works.

'; return; } container.innerHTML = ''; for (const sheet of state.sheets) { const el = document.createElement('div'); el.className = 'sheet-item' + (sheet.id === state.activeSheetId ? ' active' : ''); el.innerHTML = `${escapeHtml(sheet.filename)}${sheet.sprite_count}`; el.addEventListener('click', () => selectSheet(sheet.id)); container.appendChild(el); } document.getElementById('topbar-summary').innerHTML = `${state.sheets.length} sheet${state.sheets.length === 1 ? '' : 's'} · ${state.sheets.reduce((a, s) => a + s.sprite_count, 0)} sprites detected`; } async function selectSheet(sheetId, keepSelection = false) { state.activeSheetId = sheetId; const { sprites } = await API.listSprites({ sheet_id: sheetId }); state.spritesBySheet[sheetId] = sprites; if (!keepSelection) { state.selectedSpriteId = null; exitPickMode(); } renderSheetList(); renderCanvas(); renderInspector(); updateBulkScopeHint(); } function renderCanvas() { const scroll = document.getElementById('canvas-scroll'); const sheet = state.sheets.find(s => s.id === state.activeSheetId); const zoomControls = document.getElementById('zoom-controls'); if (!sheet) { document.getElementById('canvas-title').textContent = 'No sheet selected'; zoomControls.style.display = 'none'; scroll.innerHTML = `
░▒▓
Upload a sprite sheet to begin.
Detected sprites will appear here as numbered boxes — green for high confidence, amber for ones worth a second look.
`; return; } document.getElementById('canvas-title').textContent = `${sheet.filename} — ${sheet.width}×${sheet.height}px — ${sheet.sprite_count} sprite${sheet.sprite_count === 1 ? '' : 's'} detected`; zoomControls.style.display = 'flex'; const sprites = currentSprites(); const displayScale = computeDisplayScale(sheet.width, sheet.height) * state.zoom; const stage = document.createElement('div'); stage.className = 'sheet-stage'; stage.style.width = `${sheet.width * displayScale}px`; stage.style.height = `${sheet.height * displayScale}px`; const img = document.createElement('img'); img.src = `/api/sheet/${sheet.id}/image`; img.width = sheet.width * displayScale; img.height = sheet.height * displayScale; img.alt = sheet.filename; img.draggable = false; stage.appendChild(img); for (const sprite of sprites) { stage.appendChild(buildBboxEl(sprite, displayScale)); } scroll.innerHTML = ''; scroll.appendChild(stage); } function computeDisplayScale(w, h) { // Pick a scale that keeps small sheets legible and large sheets // reasonably sized, always a whole-pixel-friendly value where possible. // The target viewport width adapts to the actual canvas area instead of // a desktop-sized constant, so a phone-width screen doesn't force an // oversized sheet into permanent horizontal scroll on first load. const scrollEl = document.getElementById('canvas-scroll'); const available = scrollEl ? scrollEl.clientWidth - 32 : 900; // minus padding const target = Math.max(240, Math.min(900, available)); let scale = target / Math.max(w, h); if (scale > 8) scale = 8; if (scale < 0.15) scale = 0.15; return scale; } function confidenceTier(confidence) { if (confidence >= 85) return 'high'; if (confidence >= 70) return 'mid'; return 'low'; } function confidenceColorVar(confidence) { const tier = confidenceTier(confidence); if (tier === 'high') return 'var(--accent)'; if (tier === 'mid') return 'var(--warn)'; return 'var(--danger)'; } function buildBboxEl(sprite, displayScale) { const el = document.createElement('div'); el.className = 'bbox' + (sprite.id === state.selectedSpriteId ? ' selected' : '') + ` status-${sprite.status}`; el.style.setProperty('--bbox-color', confidenceColorVar(sprite.confidence)); el.style.left = `${sprite.bbox.x * displayScale}px`; el.style.top = `${sprite.bbox.y * displayScale}px`; el.style.width = `${Math.max(sprite.bbox.width * displayScale, 4)}px`; el.style.height = `${Math.max(sprite.bbox.height * displayScale, 4)}px`; const tag = document.createElement('span'); tag.className = 'bbox-tag'; tag.textContent = `#${sprite.sprite_index}`; el.appendChild(tag); el.title = `Sprite #${sprite.sprite_index} — ${Math.round(sprite.confidence)}% confidence — ${sprite.status}`; el.addEventListener('click', (e) => { e.stopPropagation(); handleBboxClick(sprite.id); }); return el; } function handleBboxClick(spriteId) { if (state.mode === 'merge-pick') { toggleMergeSelection(spriteId); return; } selectSprite(spriteId); } function selectSprite(spriteId) { state.selectedSpriteId = spriteId; renderCanvas(); renderInspector(); scrollInspectorIntoViewOnMobile(); } function scrollInspectorIntoViewOnMobile() { // On the stacked mobile layout the inspector pane sits below the // canvas in normal page flow, so selecting a sprite can leave the // reviewer looking at a canvas with no visible feedback that anything // happened. On desktop the inspector is always in view in its own // column, so this is a no-op there. if (window.innerWidth > 880) return; const pane = document.getElementById('pane-right'); if (pane) pane.scrollIntoView({ behavior: 'smooth', block: 'start' }); } /* ========================================================================== Inspector panel ========================================================================== */ function renderInspector() { const root = document.getElementById('inspector'); const sprite = state.selectedSpriteId ? findSprite(state.selectedSpriteId) : null; if (state.mode === 'merge-pick') { root.innerHTML = buildMergePickPanel(); wireMergePickPanel(); return; } if (!sprite) { root.innerHTML = '
Select a sprite on the canvas to review it here.
'; return; } const q = sprite.quality || {}; const tier = confidenceTier(sprite.confidence); const tierLabel = tier === 'high' ? 'Confident detection' : tier === 'mid' ? 'Worth a second look' : 'Possible segmentation problem'; root.innerHTML = `

Sprite #${sprite.sprite_index}

Sprite #${sprite.sprite_index} preview
${sprite.id}
${sprite.bbox.width} × ${sprite.bbox.height}px ${escapeHtml(sprite.source_file)}
${Math.round(sprite.confidence)}% ${tierLabel}
${sprite.confidence_reasons && sprite.confidence_reasons.length ? ` ` : ''} ${sprite.is_duplicate_of ? `
Possible duplicate — ${Math.round(sprite.duplicate_similarity)}% similar to another sprite already in the set.
` : ''} ${sprite.auto_tagged ? `
Auto-labelled — category, style and tags below are a machine suggestion (${Math.round(sprite.auto_tag_confidence || 0)}% confidence), not a reviewed decision. Changing any of them marks this sprite as human-checked.
` : ''}
${scoreRow('Overall quality', q.overall_quality_score)} ${scoreRow('Pixel art probability', q.pixel_art_probability)} ${scoreRow('Sharpness', q.sharpness_score)} ${scoreRow('Color consistency', q.color_consistency)} ${scoreRow('Compression', q.compression_artifact_score)} ${scoreRow('Transparency quality', q.transparency_quality)} ${scoreRow('Resolution suitability', q.resolution_suitability)} ${scoreRow('Noise', q.noise_score)}
Unique colors
${q.unique_colors ?? '—'}
Components merged
${sprite.component_count}
${(sprite.tags || []).map(t => `${escapeHtml(t)}`).join('')}
`; wireInspectorEvents(sprite); } function scoreRow(label, value) { const v = value === undefined || value === null ? null : Math.round(value); return `
${label}
${v === null ? '—' : v}
`; } function scoreColor(v) { if (v === null) return 'var(--ink-faint)'; if (v >= 80) return 'var(--accent)'; if (v >= 55) return 'var(--warn)'; return 'var(--danger)'; } function wireInspectorEvents(sprite) { document.getElementById('btn-accept').addEventListener('click', () => doAccept(sprite.id)); document.getElementById('btn-reject').addEventListener('click', () => doReject(sprite.id)); document.getElementById('bg-kept').addEventListener('click', () => setPreviewVariant(sprite, 'kept')); document.getElementById('bg-transparent').addEventListener('click', () => setPreviewVariant(sprite, 'transparent')); document.getElementById('field-category').addEventListener('change', (e) => doUpdateMetadata(sprite.id, { category: e.target.value })); document.getElementById('field-style').addEventListener('change', (e) => doUpdateMetadata(sprite.id, { style: e.target.value })); const tagInput = document.getElementById('field-tag-input'); tagInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && tagInput.value.trim()) { e.preventDefault(); const newTags = [...(sprite.tags || []), tagInput.value.trim()]; doUpdateMetadata(sprite.id, { tags: newTags }); } }); document.querySelectorAll('#tag-list button').forEach(btn => { btn.addEventListener('click', () => { const newTags = (sprite.tags || []).filter(t => t !== btn.dataset.tag); doUpdateMetadata(sprite.id, { tags: newTags }); }); }); document.getElementById('btn-apply-edit').addEventListener('click', () => { const bbox = { x: parseInt(document.getElementById('edit-x').value, 10), y: parseInt(document.getElementById('edit-y').value, 10), width: parseInt(document.getElementById('edit-w').value, 10), height: parseInt(document.getElementById('edit-h').value, 10), }; if ([bbox.x, bbox.y, bbox.width, bbox.height].some(Number.isNaN) || bbox.width <= 0 || bbox.height <= 0) { toast('Enter valid numbers — width and height must be positive', true); return; } doEditBbox(sprite.id, bbox); }); document.getElementById('btn-start-merge').addEventListener('click', () => enterMergeMode(sprite.id)); document.getElementById('btn-start-split').addEventListener('click', () => openSplitModal(sprite)); } function setPreviewVariant(sprite, variant) { document.getElementById('bg-kept').classList.toggle('active', variant === 'kept'); document.getElementById('bg-transparent').classList.toggle('active', variant === 'transparent'); const img = document.querySelector('.preview-frame img'); img.src = variant === 'kept' ? sprite.preview_url : sprite.png_transparent_url + '&preview=1'; // Transparent variant has no dedicated NEAREST-preview endpoint of its // own beyond the raw crop; browsers still render it crisp because the // frame and both carry image-rendering:pixelated, so this is a // safe fallback that still looks correct at typical sprite sizes. } async function doAccept(id) { try { await API.acceptSprite(id); toast('Sprite accepted'); await afterSpriteMutation(); } catch (err) { reportError(err); } } async function doReject(id) { try { await API.rejectSprite(id); toast('Sprite rejected'); await afterSpriteMutation(); } catch (err) { reportError(err); } } async function doUpdateMetadata(id, payload) { try { const updated = await API.updateMetadata(id, payload); patchSpriteInState(updated); if (payload.category || payload.style) await refreshCategories(); renderInspector(); } catch (err) { reportError(err); } } async function doEditBbox(id, bbox) { try { const updated = await API.editBbox(id, bbox); patchSpriteInState(updated); renderCanvas(); renderInspector(); toast('Bounding box updated'); } catch (err) { reportError(err); } } function patchSpriteInState(updatedSprite) { const list = state.spritesBySheet[updatedSprite.sheet_id]; if (!list) return; const idx = list.findIndex(s => s.id === updatedSprite.id); if (idx >= 0) list[idx] = updatedSprite; else list.push(updatedSprite); } async function afterSpriteMutation() { await selectSheet(state.activeSheetId, true); await refreshSummary(); } /* ========================================================================== Merge flow ========================================================================== */ function enterMergeMode(firstSpriteId) { state.mode = 'merge-pick'; state.mergeSelection = [firstSpriteId]; renderCanvas(); renderInspector(); toast('Click another sprite on the canvas to merge with this one'); } function toggleMergeSelection(spriteId) { const i = state.mergeSelection.indexOf(spriteId); if (i >= 0) { state.mergeSelection.splice(i, 1); } else { state.mergeSelection.push(spriteId); } if (state.mergeSelection.length > 2) state.mergeSelection.shift(); renderCanvas(); renderInspector(); } function buildMergePickPanel() { const picked = state.mergeSelection.map(id => findSprite(id)).filter(Boolean); return `

Merge sprites

${picked.map(s => `#${s.sprite_index}`).join('') || 'none yet'}
`; } function wireMergePickPanel() { document.getElementById('btn-cancel-merge').addEventListener('click', exitPickMode); const confirmBtn = document.getElementById('btn-confirm-merge'); if (confirmBtn) { confirmBtn.addEventListener('click', async () => { try { const merged = await API.mergeSprites(state.mergeSelection); toast(`Merged into sprite #${merged.sprite_index}`); exitPickMode(); await selectSheet(state.activeSheetId, true); state.selectedSpriteId = merged.id; renderCanvas(); renderInspector(); await refreshSummary(); } catch (err) { reportError(err); } }); } } function exitPickMode() { state.mode = 'browse'; state.mergeSelection = []; } /* ========================================================================== Split flow (modal with two editable boxes seeded from the sprite bbox) ========================================================================== */ function openSplitModal(sprite) { const root = document.getElementById('modal-root'); const b = sprite.bbox; // Seed two side-by-side halves as a starting point the user can then // adjust — most splits are "this merged two side-by-side things", so a // horizontal half-split is the most common useful default. const half = Math.max(1, Math.floor(b.width / 2)); root.innerHTML = ` `; document.getElementById('split-cancel').addEventListener('click', closeModal); document.getElementById('split-backdrop').addEventListener('click', (e) => { if (e.target.id === 'split-backdrop') closeModal(); }); document.getElementById('split-confirm').addEventListener('click', async () => { const boxA = readBoxInputs('split-a'); const boxB = readBoxInputs('split-b'); if (!boxA || !boxB) { toast('Enter valid numbers for both boxes — width and height must be positive', true); return; } try { const result = await API.splitSprite(sprite.id, [boxA, boxB]); toast(`Split into #${result.sprite_a.sprite_index} and #${result.sprite_b.sprite_index}`); closeModal(); await selectSheet(state.activeSheetId, true); state.selectedSpriteId = result.sprite_a.id; renderCanvas(); renderInspector(); await refreshSummary(); } catch (err) { reportError(err); } }); } function readBoxInputs(prefix) { const x = parseInt(document.getElementById(`${prefix}-x`).value, 10); const y = parseInt(document.getElementById(`${prefix}-y`).value, 10); const width = parseInt(document.getElementById(`${prefix}-w`).value, 10); const height = parseInt(document.getElementById(`${prefix}-h`).value, 10); if ([x, y, width, height].some(Number.isNaN) || width <= 0 || height <= 0) return null; return { x, y, width, height }; } function closeModal() { document.getElementById('modal-root').innerHTML = ''; } /* ========================================================================== Batch controls + dataset summary + HF upload ========================================================================== */ async function refreshSummary() { try { const summary = await API.datasetSummary(); document.getElementById('stat-pending').textContent = summary.pending; document.getElementById('stat-accepted').textContent = summary.accepted; document.getElementById('stat-rejected').textContent = summary.rejected; document.getElementById('stat-duplicates').textContent = summary.duplicates; } catch (err) { console.error(err); } } async function refreshCategories() { const { categories, styles } = await API.getCategories(); state.categories = categories; state.styles = styles; renderCategoryOptions(); } function setupBatchControls() { document.getElementById('btn-accept-above').addEventListener('click', async () => { try { const result = await API.batchAcceptAbove(90); toast(`Accepted ${result.accepted_count} sprite(s) scoring 90+`); await selectSheet(state.activeSheetId, true); await refreshSummary(); } catch (err) { reportError(err); } }); document.getElementById('btn-dedupe-keep-one').addEventListener('click', async () => { try { const result = await API.batchDedupeKeepOne(); if (!result.cluster_count) { toast('No duplicate clusters among pending sprites'); } else { toast(`${result.cluster_count} cluster(s): kept ${result.kept_count}, rejected ${result.rejected_count} redundant copy(ies)`); } await selectSheet(state.activeSheetId, true); await refreshSummary(); } catch (err) { reportError(err); } }); document.getElementById('btn-dataset-report').addEventListener('click', openDatasetReport); document.getElementById('btn-reject-dupes').addEventListener('click', async () => { try { const result = await API.batchRejectDuplicates(); toast(`Rejected ${result.rejected_count} duplicate(s)`); await selectSheet(state.activeSheetId, true); await refreshSummary(); } catch (err) { reportError(err); } }); document.getElementById('btn-review-low-conf').addEventListener('click', async () => { try { const { sprites } = await API.batchLowConfidence(); if (!sprites.length) { toast('No low-confidence sprites pending review'); return; } // Jump to the first low-confidence sprite's sheet and select it — // the fastest path into what actually needs a human look. await selectSheet(sprites[0].sheet_id); selectSprite(sprites[0].id); toast(`${sprites.length} sprite(s) need review — showing the first`); } catch (err) { reportError(err); } }); document.getElementById('btn-upload-hf').addEventListener('click', async () => { const btn = document.getElementById('btn-upload-hf'); const original = btn.textContent; btn.disabled = true; btn.textContent = 'Uploading…'; try { const result = await API.datasetUpload(); if (result.success) { if (result.uploaded_count === 0) { toast('Nothing to upload — accept some sprites first'); } else { toast(`Uploaded ${result.uploaded_count} sprite(s) to the dataset in one batch commit`); } await refreshSummary(); } else { toast(result.error || 'Upload failed', true); } } catch (err) { reportError(err); } finally { btn.disabled = false; btn.textContent = original; } }); } /* ========================================================================== Bulk metadata assignment The realistic labelling workflow for a sheet of 300 tiles is not "click 300 times", it is "this whole sheet is trees". Tags are ADDITIVE by default so a bulk pass adds "forest" on top of whatever the auto-tagger worked out per sprite, instead of flattening every sprite to the same tag list. ========================================================================== */ function setupBulkAssign() { const scopeSelect = document.getElementById('bulk-scope'); scopeSelect.addEventListener('change', updateBulkScopeHint); document.getElementById('btn-bulk-apply').addEventListener('click', async () => { const scope = scopeSelect.value; if (scope === 'sheet' && !state.activeSheetId) { toast('Select a sheet first', true); return; } const category = document.getElementById('bulk-category').value.trim(); const style = document.getElementById('bulk-style').value.trim(); const rawTags = document.getElementById('bulk-tags').value.trim(); const replace = document.getElementById('bulk-replace-tags').checked; const tags = rawTags ? rawTags.split(',').map(t => t.trim()).filter(Boolean) : []; if (!category && !style && !tags.length) { toast('Enter a category, a style or some tags first', true); return; } const payload = { scope, category, style }; if (scope === 'sheet') payload.sheet_id = state.activeSheetId; if (scope === 'status') { payload.scope = 'status'; payload.status = 'pending'; } if (replace) payload.replace_tags = tags; else payload.add_tags = tags; try { const result = await API.batchAssign(payload); toast(`Updated ${result.updated_count} sprite(s)`); document.getElementById('bulk-tags').value = ''; await refreshCategories(); renderCategoryOptions(); if (state.activeSheetId) await selectSheet(state.activeSheetId, true); } catch (err) { reportError(err); } }); updateBulkScopeHint(); } function updateBulkScopeHint() { const scope = document.getElementById('bulk-scope').value; const hint = document.getElementById('bulk-scope-hint'); const sheet = state.sheets.find(s => s.id === state.activeSheetId); if (scope === 'sheet') { hint.textContent = sheet ? `${sheet.sprite_count} sprite(s) on ${sheet.filename}` : 'No sheet selected yet.'; } else if (scope === 'status') { hint.textContent = 'Every sprite still awaiting review, across all sheets.'; } else { hint.textContent = 'Every sprite in the database, whatever its status.'; } } function renderCategoryOptions() { const catList = document.getElementById('bulk-category-options'); const styleList = document.getElementById('bulk-style-options'); if (catList) catList.innerHTML = state.categories.map(c => `