` : `
🖼`; const dlDisabled = entry.status !== 'done'; const savingsHtml = showSav && entry.outputSize ? `
↓${Math.round((1 - entry.outputSize / entry.inputSize) * 100)}%` : ''; card.innerHTML = `
${thumbHtml}
${statusLabel} ${getOutputName(entry.name)}
${fmt(entry.inputSize)} → ${entry.outputSize ? fmt(entry.outputSize) : '—'} ${savingsHtml}
`; const thumbWrap = card.querySelector('.img-thumb-wrap'); thumbWrap.addEventListener('click', () => { if (!entry.outputDataUrl) return; modalImg.src = entry.outputDataUrl; modalName.textContent = getOutputName(entry.name); previewModal.classList.add('open'); }); thumbWrap.addEventListener('keydown', e => { if (e.key === 'Enter') thumbWrap.click(); }); card.querySelector('.rm-btn').addEventListener('click', () => { state.files = state.files.filter(f => f.id !== entry.id); render(); }); card.querySelector('.dl-btn').addEventListener('click', () => { if (!entry.outputBlob) return; const a = document.createElement('a'); a.href = URL.createObjectURL(entry.outputBlob); a.download = getOutputName(entry.name); a.click(); URL.revokeObjectURL(a.href); showToast(`✅ Saved "${getOutputName(entry.name)}"`, 'success'); }); cardGrid.appendChild(card); }); } /* ── FORMAT BYTES ── */ function fmt(bytes) { if (bytes === null || bytes === undefined) return '—'; if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB'; if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'; return bytes + ' B'; } /* ── CONVERT ALL ── */ convertBtn.addEventListener('click', convertAll); async function convertAll() { const pending = state.files.filter(f => f.status !== 'done'); if (!pending.length) { showToast('All files already converted.', 'success'); return; } state.converting = true; convertBtn.disabled = true; progressSection.classList.add('active'); const quality = parseInt(qualitySlider.value) / 100; const bg = state.bgColor; const total = pending.length; let done = 0; for (const entry of pending) { entry.status = 'working'; render(); progLabel.textContent = `Converting "${entry.name}"…`; progCount.textContent = `${done} / ${total}`; progBar.style.width = `${Math.round((done / total) * 100)}%`; try { const { blob, dataUrl, size } = await convertToJpg(entry.file, quality, bg); entry.outputBlob = blob; entry.outputSize = size; entry.outputDataUrl = dataUrl; entry.status = 'done'; } catch (err) { console.error(err); entry.status = 'error'; } done++; render(); } progBar.style.width = '100%'; progLabel.textContent = 'Done!'; progCount.textContent = `${done} / ${total}`; state.converting = false; convertBtn.disabled = false; const errors = state.files.filter(f => f.status === 'error').length; showToast( errors ? `⚠️ ${done - errors} converted, ${errors} failed.` : `✅ ${done} file${done > 1 ? 's' : ''} converted!`, errors ? 'error' : 'success' ); setTimeout(() => progressSection.classList.remove('active'), 2000); } /* ── CONVERT SINGLE ── */ function convertToJpg(file, quality, bgColor) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('File read failed')); reader.onload = ev => { const img = new Image(); img.onerror = () => reject(new Error('Image decode failed')); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d'); ctx.fillStyle = (bgColor && bgColor !== 'transparent') ? bgColor : '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0); canvas.toBlob(blob => { if (!blob) return reject(new Error('Conversion failed')); const reader2 = new FileReader(); reader2.onload = e2 => resolve({ blob, dataUrl: e2.target.result, size: blob.size }); reader2.onerror = () => reject(new Error('Output read failed')); reader2.readAsDataURL(blob); }, 'image/jpeg', quality); }; img.src = ev.target.result; }; reader.readAsDataURL(file); }); } /* ── ZIP ── */ zipBtn.addEventListener('click', async () => { const done = state.files.filter(f => f.status === 'done'); if (!done.length) return; zipBtn.disabled = true; zipBtn.textContent = '⏳ Building ZIP…'; try { const zip = new JSZip(); done.forEach(entry => zip.file(getOutputName(entry.name), entry.outputBlob)); const content = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 3 } }); const a = document.createElement('a'); a.href = URL.createObjectURL(content); a.download = 'converted-images.zip'; a.click(); URL.revokeObjectURL(a.href); showToast(`📦 ZIP with ${done.length} files downloaded!`, 'success'); } catch (err) { console.error(err); showToast('❌ ZIP creation failed.', 'error'); } finally { zipBtn.disabled = false; zipBtn.textContent = '📦 Download ZIP'; render(); } }); /* ── MODAL ── */ modalCloseBtn.addEventListener('click', () => previewModal.classList.remove('open')); previewModal.addEventListener('click', e => { if (e.target === previewModal) previewModal.classList.remove('open'); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') previewModal.classList.remove('open'); }); /* ── TOAST ── */ let toastTimer; function showToast(msg, type = 'success') { const toast = document.getElementById('toast'); toast.textContent = msg; toast.className = `show ${type === 'success' ? 'success-t' : 'error-t'}`; clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.className = '', 3400); } /* ── INIT ── */ render();