571 lines
28 KiB
HTML
571 lines
28 KiB
HTML
<!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 id="limitsHint" 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'];
|
||
let MAX_SIZE = 20 * 1024 * 1024;
|
||
let isAdmin = false;
|
||
// Админу — без лимитов по размеру/количеству
|
||
(function(){ fetch('/auth/me').then(function(r){return r.ok?r.json():null}).then(function(u){
|
||
if (u && u.role === 'admin') {
|
||
isAdmin = true;
|
||
MAX_SIZE = Infinity;
|
||
var d = document.getElementById('limitsHint');
|
||
if (d) d.textContent = 'Сжатие, ресайз и конвертация · без лимитов';
|
||
}
|
||
}).catch(function(){}); })();
|
||
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
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>
|