wa-dev-tools/public/compress.html

561 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Конвертер изображений — WA Dev Tools</title>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'compress';</script>
<style>
/* ── Dropzone ── */
.dropzone-wrap { position: relative; border: 2px dashed var(--surface-600); border-radius: 14px; padding: 40px 20px; text-align: center; cursor: pointer; transition: border-color .25s, background .25s; }
.dropzone-wrap:hover, .dropzone-wrap.active { border-color: var(--accent); background: var(--accent-bg); }
.dropzone-wrap .drop-icon { transition: transform .3s cubic-bezier(.34,1.56,.64,1); }
.dropzone-wrap.active .drop-icon { transform: scale(1.12) translateY(-4px); }
/* ── Settings bar ── */
.settings-row { display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap; }
.settings-row > * { flex: 1 1 120px; min-width: 0; }
.ctrl-label { font-size: 10px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-bottom: 5px; }
.ctrl-select { width: 100%; padding: 8px 10px; background: var(--select-bg); color: var(--select-color); border: 1px solid var(--surface-600); border-radius: 8px; font-size: 13px; outline: none; transition: border-color .2s; }
.ctrl-select:focus { border-color: var(--accent); }
.ctrl-select option { background: var(--select-bg); color: var(--select-color); }
.quality-val { font-size: 11px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-top: 3px; text-align: center; }
/* ── Summary bar ── */
.summary-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--surface-700); border: 1px solid var(--surface-600); border-radius: 12px; margin-top: 20px; }
.summary-bar.hidden { display: none; }
.summary-stat { font-size: 12px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); }
.summary-stat strong { color: var(--text-primary); }
.summary-savings { font-size: 18px; font-weight: 800; font-family: 'JetBrains Mono', monospace; }
/* ── File list ── */
.file-list { margin-top: 12px; border: 1px solid var(--surface-600); border-radius: 12px; overflow: hidden; }
.file-list.hidden { display: none; }
.file-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--surface-600); transition: background .15s; }
.file-row:last-child { border-bottom: none; }
.file-row:hover { background: var(--accent-bg); }
/* Thumbnail */
.file-thumb { width: 44px; height: 44px; border-radius: 7px; object-fit: cover; border: 1px solid var(--surface-600); flex-shrink: 0; background: var(--surface-700); }
.file-thumb-placeholder { width: 44px; height: 44px; border-radius: 7px; border: 1px solid var(--surface-600); flex-shrink: 0; background: var(--surface-700); display: flex; align-items: center; justify-content: center; }
/* Name + format */
.file-info { flex: 1 1 0; min-width: 0; }
.file-name { font-size: 13px; font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.fmt-badge { font-size: 10px; font-weight: 700; letter-spacing: .05em; padding: 2px 6px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; }
.fmt-jpeg { background: rgba(0,188,212,.15); color: #00bcd4; }
.fmt-png { background: rgba(76,175,80,.15); color: #4caf50; }
.fmt-webp { background: rgba(156,39,176,.15); color: #9c27b0; }
.fmt-avif { background: rgba(255,152,0,.15); color: #ff9800; }
.fmt-tiff { background: rgba(33,150,243,.15); color: #2196f3; }
.fmt-gif { background: rgba(233,30,99,.15); color: #e91e63; }
.fmt-other{ background: rgba(128,128,128,.15);color: #888; }
.file-orig-size { font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
/* Right side: result */
.file-result { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.savings-pct { font-size: 15px; font-weight: 800; font-family: 'JetBrains Mono', monospace; min-width: 46px; text-align: right; }
.savings-positive { color: #4caf50; }
.savings-mid { color: var(--accent); }
.savings-low { color: #ff9800; }
.savings-negative { color: #f44336; }
.new-size { font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; text-align: right; margin-top: 1px; }
/* Download btn per file */
.dl-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; font-family: 'JetBrains Mono', monospace; letter-spacing: .04em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer; text-decoration: none; transition: background .2s, color .2s; white-space: nowrap; }
.dl-btn:hover { background: var(--accent); color: #fff; }
.dl-btn svg { flex-shrink: 0; }
/* Loading spinner row */
.file-row.loading .savings-pct { display: none; }
.file-row.loading .dl-btn { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { width: 18px; height: 18px; border: 2px solid var(--surface-600); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; flex-shrink: 0; }
/* Download all */
.dl-all-btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 13px; background: var(--accent); color: #fff; border: none; border-radius: 12px; font-size: 14px; font-weight: 700; cursor: pointer; transition: background .2s, box-shadow .2s; margin-top: 14px; text-decoration: none; }
.dl-all-btn:hover { background: var(--accent-dim); box-shadow: 0 6px 20px rgba(0,84,230,.25); }
.dl-all-btn.hidden { display: none; }
/* Animations */
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp .35s ease-out forwards; }
.fade-up-delay { animation: fadeUp .35s ease-out .1s forwards; opacity: 0; }
.file-row { animation: fadeUp .3s ease-out forwards; }
/* Error */
.error-block { background: rgba(244,67,54,.08); border: 1px solid rgba(244,67,54,.2); border-radius: 10px; padding: 12px 16px; font-size: 13px; color: #f44336; margin-top: 14px; }
.error-block.hidden { display: none; }
/* Responsive */
@media (max-width: 600px) {
.settings-row { gap: 8px; }
.settings-row > * { flex: 1 1 100px; }
.file-row { gap: 8px; padding: 8px 10px; }
.summary-bar { flex-direction: column; align-items: flex-start; gap: 6px; }
}
@media (max-width: 420px) {
.file-result { flex-direction: column; align-items: flex-end; gap: 4px; }
}
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<div class="main-content">
<div class="tool-container relative z-10 max-w-2xl mx-auto py-8 sm:py-12">
<!-- Header -->
<div class="text-center mb-8 fade-up">
<div class="page-header" style="text-align:center;">
<div class="inline-flex items-center gap-2 mb-3 px-3 py-1 rounded-full border dark:border-surface-600 border-gray-300 dark:bg-surface-800/60 bg-white/60 text-xs font-mono dark:text-gray-400 text-gray-500 tracking-wide">
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
JPEG · PNG · WebP · AVIF · TIFF · GIF
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Конвертер <span class="text-accent">изображений</span>
</h1>
<p class="description mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">Сжатие, ресайз и конвертация · до 50 файлов · до 20 МБ</p>
</div>
</div>
<!-- Main Card -->
<div class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl p-5 sm:p-7 backdrop-blur-sm fade-up-delay shadow-sm dark:shadow-none">
<!-- Dropzone -->
<div id="dropzone" class="dropzone-wrap" role="button" tabindex="0" aria-label="Зона загрузки файлов">
<div class="drop-icon mb-3">
<svg class="mx-auto w-10 h-10" style="color:var(--text-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
</div>
<p style="font-size:14px;color:var(--text-muted)">
Перетащите файлы сюда или <span style="color:var(--accent);font-weight:600">выберите</span>
</p>
<p style="font-size:11px;color:var(--text-muted);margin-top:4px;font-family:'JetBrains Mono',monospace">Файлы загружаются мгновенно, по одному</p>
<input type="file" multiple accept="image/*" id="fileInput" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;" tabindex="-1">
</div>
<!-- Settings row -->
<div class="settings-row">
<div>
<div class="ctrl-label">Формат</div>
<select id="format" class="ctrl-select">
<option value="original">Оригинал</option>
<option value="webp">WebP</option>
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
<option value="avif">AVIF</option>
<option value="tiff">TIFF</option>
<option value="gif">GIF</option>
</select>
</div>
<div>
<div class="ctrl-label">Размер</div>
<select id="resize" class="ctrl-select">
<option value="0">Не изменять</option>
<option value="4600">4600px</option>
<option value="3600">3600px</option>
<option value="2600">2600px</option>
<option value="1600">1600px</option>
<option value="1200">1200px</option>
<option value="800">800px</option>
<option value="custom">Свой размер...</option>
</select>
</div>
<div>
<div class="ctrl-label">Качество</div>
<input type="range" id="quality" min="10" max="100" value="80" style="width:100%"
oninput="document.getElementById('qualityVal').textContent=this.value+'%'">
<div class="quality-val" id="qualityVal">80%</div>
</div>
</div>
</div>
<!-- Error block -->
<div id="errorBlock" class="error-block hidden"></div>
<!-- Summary bar -->
<div id="summaryBar" class="summary-bar hidden">
<div>
<div style="font-size:13px;font-weight:700;color:var(--text-primary)" id="summaryTitle">0 файлов</div>
<div class="summary-stat" id="summaryDetail"></div>
</div>
<div style="text-align:right">
<div class="summary-savings" id="summarySavings" style="color:var(--accent)"></div>
<div style="font-size:11px;color:var(--text-muted)">общая экономия</div>
</div>
</div>
<!-- File list -->
<div id="fileList" class="file-list hidden"></div>
<!-- Download all -->
<button id="dlAllBtn" class="dl-all-btn hidden" type="button">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Скачать всё архивом
</button>
</div>
</div><!-- /main-content -->
<script>
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmtSize(bytes) {
if (bytes < 1024) return bytes + ' Б';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' КБ';
return (bytes / (1024 * 1024)).toFixed(2) + ' МБ';
}
function fmtBadge(mimetype, outputName) {
const ext = (outputName || '').split('.').pop().toLowerCase();
const typeMap = {
'image/jpeg': 'jpeg', 'image/jpg': 'jpeg',
'image/png': 'png',
'image/webp': 'webp',
'image/avif': 'avif',
'image/tiff': 'tiff',
'image/gif': 'gif',
};
let fmt = typeMap[mimetype] || ext || 'file';
const label = fmt.toUpperCase();
const cls = ['jpeg','png','webp','avif','tiff','gif'].includes(fmt) ? 'fmt-' + fmt : 'fmt-other';
return '<span class="fmt-badge ' + cls + '">' + label + '</span>';
}
function savingsClass(pct) {
if (pct >= 30) return 'savings-positive';
if (pct >= 10) return 'savings-mid';
if (pct >= 0) return 'savings-low';
return 'savings-negative';
}
function dlIcon() {
return '<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>';
}
function showError(msg) {
const el = document.getElementById('errorBlock');
el.textContent = msg;
el.classList.remove('hidden');
setTimeout(() => el.classList.add('hidden'), 6000);
}
// ── State ─────────────────────────────────────────────────────────────────────
// Each entry: { id, file, rowEl, status: 'pending'|'uploading'|'done'|'error', result }
const fileEntries = [];
let entryCounter = 0;
let pendingQueue = [];
let activeUploads = 0;
const MAX_CONCURRENT = 3;
// Batch download state (completed results)
let batchFiles = []; // {originalFile, result}
// ── DOM refs ─────────────────────────────────────────────────────────────────
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
const summaryBar = document.getElementById('summaryBar');
const dlAllBtn = document.getElementById('dlAllBtn');
// ── Dropzone events ───────────────────────────────────────────────────────────
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); });
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('active'); });
dropzone.addEventListener('dragleave', e => { if (!dropzone.contains(e.relatedTarget)) dropzone.classList.remove('active'); });
dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('active'); handleFiles(e.dataTransfer.files); });
fileInput.addEventListener('change', e => { handleFiles(e.target.files); e.target.value = ''; });
// ── File handling ─────────────────────────────────────────────────────────────
// Handle custom resize: prompt immediately on select change
document.getElementById('resize').addEventListener('change', function() {
if (this.value === 'custom') {
const val = prompt('Размер по длинной стороне (px):', '1024');
if (val && parseInt(val) > 0) {
// Add a custom option with the value
let customOpt = this.querySelector('option[data-custom]');
if (!customOpt) {
customOpt = document.createElement('option');
customOpt.setAttribute('data-custom', '1');
this.appendChild(customOpt);
}
customOpt.value = val;
customOpt.textContent = val + 'px';
this.value = val;
} else {
this.value = '0'; // reset to "Не изменять"
}
}
});
const VALID_TYPES = ['image/jpeg','image/png','image/webp','image/avif','image/tiff','image/gif','image/bmp','image/svg+xml'];
const MAX_SIZE = 20 * 1024 * 1024;
function handleFiles(files) {
const arr = Array.from(files);
const valid = arr.filter(f => (VALID_TYPES.includes(f.type) || /\.(jpe?g|png|webp|avif|tiff?|gif|bmp|svg|heic|heif)$/i.test(f.name)) && f.size <= MAX_SIZE);
const invalid = arr.length - valid.length;
if (!valid.length) {
showError('Нет подходящих файлов. Допустимы JPEG, PNG, WebP, AVIF, TIFF, GIF, BMP, HEIC до 20 МБ.');
return;
}
if (invalid > 0) {
showError(invalid + ' файл(ов) пропущено (неверный формат или размер > 20 МБ)');
}
valid.forEach(f => addFileEntry(f));
drainQueue();
}
function addFileEntry(file) {
const id = ++entryCounter;
const rowEl = createRow(id, file);
fileList.classList.remove('hidden');
fileList.appendChild(rowEl);
summaryBar.classList.remove('hidden');
dlAllBtn.classList.add('hidden'); // hide until all done
const entry = { id, file, rowEl, status: 'pending', result: null };
fileEntries.push(entry);
pendingQueue.push(entry);
}
function createRow(id, file) {
const row = document.createElement('div');
row.className = 'file-row loading';
row.id = 'row-' + id;
// Thumbnail placeholder
const thumbWrap = document.createElement('div');
thumbWrap.className = 'file-thumb-placeholder';
thumbWrap.id = 'thumb-' + id;
thumbWrap.innerHTML = '<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" style="color:var(--text-muted)"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg>';
row.appendChild(thumbWrap);
// Load thumbnail
const reader = new FileReader();
reader.onload = e => {
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'file-thumb';
img.alt = '';
const ph = document.getElementById('thumb-' + id);
if (ph) ph.replaceWith(img);
};
reader.readAsDataURL(file);
// Info
const info = document.createElement('div');
info.className = 'file-info';
info.innerHTML = '<div class="file-name" title="' + escHtml(file.name) + '">' + escHtml(truncName(file.name, 38)) + '</div>'
+ '<div class="file-meta">'
+ fmtBadge(file.type, file.name)
+ '<span class="file-orig-size">' + fmtSize(file.size) + '</span>'
+ '</div>';
row.appendChild(info);
// Result area
const result = document.createElement('div');
result.className = 'file-result';
result.id = 'result-' + id;
result.innerHTML = '<div class="spinner"></div>';
row.appendChild(result);
return row;
}
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function truncName(name, maxLen) {
if (name.length <= maxLen) return name;
const ext = name.lastIndexOf('.');
if (ext > 0) {
const e = name.slice(ext);
return name.slice(0, maxLen - e.length - 3) + '...' + e;
}
return name.slice(0, maxLen - 3) + '...';
}
// ── Upload queue ──────────────────────────────────────────────────────────────
function drainQueue() {
while (activeUploads < MAX_CONCURRENT && pendingQueue.length > 0) {
const entry = pendingQueue.shift();
uploadEntry(entry);
}
}
function uploadEntry(entry) {
activeUploads++;
entry.status = 'uploading';
const formData = new FormData();
formData.append('image', entry.file);
formData.append('format', document.getElementById('format').value);
formData.append('quality', document.getElementById('quality').value);
let rv = document.getElementById('resize').value;
if (rv === 'custom') rv = '0';
formData.append('resize', rv);
fetch('/compress/single', { method: 'POST', body: formData })
.then(r => {
if (!r.ok) return r.json().then(j => Promise.reject(j.error || 'Ошибка ' + r.status));
return r.json();
})
.then(data => {
entry.status = 'done';
entry.result = data;
renderRowResult(entry, data);
batchFiles.push({ file: entry.file, result: data });
updateSummary();
})
.catch(err => {
entry.status = 'error';
renderRowError(entry, typeof err === 'string' ? err : 'Ошибка загрузки');
updateSummary();
})
.finally(() => {
activeUploads--;
drainQueue();
checkAllDone();
});
}
function renderRowResult(entry, data) {
const row = entry.rowEl;
row.classList.remove('loading');
const resultEl = document.getElementById('result-' + entry.id);
if (!resultEl) return;
const pct = data.savings;
const cls = savingsClass(pct);
const sign = pct > 0 ? '' : (pct < 0 ? '+' : '');
const absPct = Math.abs(pct);
// Determine format badge for output
const outFmt = data.outputFilename ? data.outputFilename.split('.').pop().toUpperCase() : 'FILE';
resultEl.innerHTML =
'<div style="text-align:right">'
+ '<div class="savings-pct ' + cls + '">' + sign + absPct + '%</div>'
+ '<div class="new-size">' + fmtSize(data.compressedSize) + '</div>'
+ '</div>'
+ '<a href="' + escHtml(data.downloadUrl) + '" download="' + escHtml(data.outputFilename) + '" class="dl-btn">'
+ dlIcon() + outFmt
+ '</a>';
}
function renderRowError(entry, msg) {
const row = entry.rowEl;
row.classList.remove('loading');
const resultEl = document.getElementById('result-' + entry.id);
if (!resultEl) return;
resultEl.innerHTML = '<span style="font-size:11px;color:#f44336;font-family:\'JetBrains Mono\',monospace" title="' + escHtml(msg) + '">Ошибка</span>';
}
function updateSummary() {
const done = fileEntries.filter(e => e.status === 'done' && e.result);
const total = fileEntries.length;
const processing = fileEntries.filter(e => e.status === 'pending' || e.status === 'uploading').length;
const titleEl = document.getElementById('summaryTitle');
const detailEl = document.getElementById('summaryDetail');
const savingsEl = document.getElementById('summarySavings');
if (processing > 0) {
titleEl.textContent = 'Обработка... (' + (total - processing) + '/' + total + ')';
} else {
titleEl.textContent = pluralFiles(total);
}
if (done.length > 0) {
const totalOrig = done.reduce((s, e) => s + e.result.originalSize, 0);
const totalComp = done.reduce((s, e) => s + e.result.compressedSize, 0);
const pct = totalOrig > 0 ? Math.round((1 - totalComp / totalOrig) * 100) : 0;
detailEl.textContent = fmtSize(totalOrig) + ' → ' + fmtSize(totalComp);
const sign = pct > 0 ? '' : (pct < 0 ? '+' : '');
savingsEl.textContent = sign + Math.abs(pct) + '%';
savingsEl.className = 'summary-savings ' + savingsClass(pct);
} else {
detailEl.textContent = '';
savingsEl.textContent = '—';
}
}
function checkAllDone() {
const allFinished = fileEntries.every(e => e.status === 'done' || e.status === 'error');
const hasDone = fileEntries.some(e => e.status === 'done');
if (allFinished && hasDone && fileEntries.length > 1) {
dlAllBtn.classList.remove('hidden');
}
}
function pluralFiles(n) {
const m = n % 10, m100 = n % 100;
if (m === 1 && m100 !== 11) return n + ' файл';
if (m >= 2 && m <= 4 && (m100 < 12 || m100 > 14)) return n + ' файла';
return n + ' файлов';
}
// ── Batch download (re-upload to batch endpoint) ──────────────────────────────
dlAllBtn.addEventListener('click', async () => {
dlAllBtn.disabled = true;
dlAllBtn.textContent = 'Создаю архив...';
try {
const formData = new FormData();
batchFiles.forEach(b => formData.append('images', b.file));
formData.append('format', document.getElementById('format').value);
formData.append('quality', document.getElementById('quality').value);
let rv = document.getElementById('resize').value;
if (rv === 'custom') rv = '0';
formData.append('resize', rv);
const res = await fetch('/compress', { method: 'POST', body: formData });
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || 'Ошибка ' + res.status);
}
const data = await res.json();
if (data.downloadUrl) {
const a = document.createElement('a');
a.href = data.downloadUrl;
a.download = '';
document.body.appendChild(a);
a.click();
a.remove();
}
} catch(err) {
showError('Ошибка создания архива: ' + err.message);
} finally {
dlAllBtn.disabled = false;
dlAllBtn.innerHTML = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>Скачать всё архивом';
}
});
</script>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=108185982', 'ym');
ym(108185982, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/108185982" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</body>
</html>