383 lines
20 KiB
HTML
383 lines
20 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>
|
||
<script src="/vendor/tailwind.js"></script>
|
||
<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-active { border-color: #0054e6 !important; background: rgba(0, 84, 230, 0.05) !important; }
|
||
.dropzone-active .drop-icon { transform: scale(1.1) translateY(-4px); }
|
||
.drop-icon { transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||
.savings-bar { height: 6px; border-radius: 3px; background: var(--bar-bg); overflow: hidden; }
|
||
.savings-bar-fill { height: 100%; border-radius: 3px; transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); }
|
||
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
|
||
.fade-up { animation: fadeUp 0.4s ease-out forwards; }
|
||
.fade-up-delay { animation: fadeUp 0.4s ease-out 0.1s forwards; opacity: 0; }
|
||
@keyframes pulse-border { 0%, 100% { border-color: rgba(0, 84, 230, 0.2); } 50% { border-color: rgba(0, 84, 230, 0.5); } }
|
||
.processing { animation: pulse-border 1.5s ease-in-out infinite; }
|
||
.preview-grid img { transition: transform 0.2s ease; }
|
||
.preview-grid img:hover { transform: scale(1.05); }
|
||
progress { appearance: none; height: 4px; border-radius: 2px; overflow: hidden; }
|
||
progress::-webkit-progress-bar { background: var(--bar-bg); border-radius: 2px; }
|
||
progress::-webkit-progress-value { background: linear-gradient(90deg, #0043b8, #0054e6); border-radius: 2px; transition: width 0.2s; }
|
||
select, select option { background: var(--select-bg); color: var(--select-color); }
|
||
</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="relative border-2 border-dashed dark:border-surface-600 border-gray-300 rounded-xl p-8 sm:p-10 text-center cursor-pointer dark:hover:border-gray-500 hover:border-gray-400 transition-all duration-300 group">
|
||
<div class="drop-icon mb-3">
|
||
<svg class="mx-auto w-10 h-10 dark:text-gray-500 text-gray-400 group-hover:text-accent transition-colors" 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 class="text-sm dark:text-gray-400 text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors">
|
||
Перетащите файлы сюда или <span class="text-accent font-medium">выберите</span>
|
||
</p>
|
||
<input type="file" multiple accept="image/jpeg,image/png,image/webp" id="fileInput" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
|
||
</div>
|
||
|
||
<!-- Preview -->
|
||
<div id="preview" class="preview-grid grid grid-cols-3 sm:grid-cols-4 gap-2 mt-4 hidden"></div>
|
||
|
||
<!-- File count badge -->
|
||
<div id="fileCount" class="hidden mt-3 text-center">
|
||
<span class="inline-flex items-center gap-1.5 text-xs font-mono dark:text-gray-400 text-gray-500 dark:bg-surface-700 bg-gray-100 px-3 py-1 rounded-full">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||
<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>
|
||
<span id="fileCountText">0 файлов</span>
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Controls -->
|
||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-5">
|
||
<div>
|
||
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Формат</label>
|
||
<select id="format" class="w-full px-3 py-2.5 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-lg text-sm focus:outline-none focus:border-accent/50 transition-colors">
|
||
<option value="original">Оригинал</option>
|
||
<option value="avif">AVIF</option>
|
||
<option value="tiff">TIFF</option>
|
||
<option value="gif">GIF</option>
|
||
<option value="webp">WebP</option>
|
||
<option value="jpeg">JPEG</option>
|
||
<option value="png">PNG</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Размер</label>
|
||
<select id="resize" class="w-full px-3 py-2.5 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-lg text-sm focus:outline-none focus:border-accent/50 transition-colors">
|
||
<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> <label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Качество</label> <input type="range" id="quality" min="10" max="100" value="80" class="w-full" oninput="document.getElementById('qualityVal').textContent=this.value+'%'" /> <div class="text-xs text-center dark:text-gray-500 text-gray-400 mt-1" id="qualityVal">80%</div> </div>
|
||
</div>
|
||
|
||
<!-- Progress -->
|
||
<progress id="progressBar" class="w-full mt-4 hidden" value="0" max="100"></progress>
|
||
|
||
<!-- Compress button -->
|
||
<button id="compressBtn" disabled
|
||
class="mt-5 w-full py-3 rounded-xl font-semibold text-sm tracking-wide transition-all duration-300
|
||
bg-accent/10 text-accent/40 border border-accent/10 cursor-not-allowed
|
||
enabled:bg-accent enabled:text-white enabled:border-accent enabled:cursor-pointer
|
||
enabled:hover:bg-accent-bright enabled:hover:shadow-lg enabled:hover:shadow-accent/20
|
||
enabled:active:scale-[0.98]">
|
||
Сжать
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Results -->
|
||
<div id="results" class="hidden mt-5 fade-up">
|
||
<!-- Stats card -->
|
||
<div class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl overflow-hidden shadow-sm dark:shadow-none">
|
||
<!-- Summary header -->
|
||
<div id="summary" class="flex items-center justify-between px-5 sm:px-7 py-4 border-b dark:border-surface-600 border-gray-200 dark:bg-surface-700/30 bg-gray-50/50">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center">
|
||
<svg class="w-4 h-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<div class="text-sm font-semibold dark:text-white text-gray-900" id="summaryTitle">Готово</div>
|
||
<div class="text-xs dark:text-gray-500 text-gray-400 font-mono" id="summaryDetail"></div>
|
||
</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<div class="text-lg font-bold font-mono" id="summaryPercent"></div>
|
||
<div class="text-xs dark:text-gray-500 text-gray-400">экономия</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stats table -->
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm">
|
||
<thead>
|
||
<tr class="text-xs font-mono dark:text-gray-500 text-gray-400 uppercase tracking-wider border-b dark:border-surface-600 border-gray-200">
|
||
<th class="text-left px-5 py-3">Файл</th>
|
||
<th class="text-right px-3 py-3">Было</th>
|
||
<th class="text-right px-3 py-3">Стало</th>
|
||
<th class="text-right px-5 py-3 w-32">Экономия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="statsBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Download button -->
|
||
<a id="downloadBtn" href="#" download
|
||
class="mt-4 flex items-center justify-center gap-2 w-full py-3.5 rounded-xl font-semibold text-sm tracking-wide
|
||
bg-accent text-white hover:bg-accent-bright transition-all duration-300
|
||
hover:shadow-lg hover:shadow-accent/20 active:scale-[0.98]">
|
||
<svg class="w-4 h-4" 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>
|
||
Скачать архив
|
||
</a>
|
||
</div>
|
||
|
||
<!-- Error -->
|
||
<div id="errorBlock" class="hidden mt-5 bg-danger/10 border border-danger/20 rounded-xl px-5 py-4 text-sm text-danger font-medium fade-up"></div>
|
||
</div>
|
||
</div><!-- /main-content -->
|
||
|
||
<script>
|
||
const $ = id => document.getElementById(id);
|
||
const dropzone = $('dropzone');
|
||
const fileInput = $('fileInput');
|
||
const progressBar = $('progressBar');
|
||
const compressBtn = $('compressBtn');
|
||
const results = $('results');
|
||
const errorBlock = $('errorBlock');
|
||
const preview = $('preview');
|
||
|
||
let selectedFiles = null;
|
||
|
||
function formatSize(bytes) {
|
||
if (bytes < 1024) return bytes + ' Б';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' КБ';
|
||
return (bytes / (1024 * 1024)).toFixed(2) + ' МБ';
|
||
}
|
||
|
||
function pluralFiles(n) {
|
||
const mod = n % 10;
|
||
const mod100 = n % 100;
|
||
if (mod === 1 && mod100 !== 11) return n + ' файл';
|
||
if (mod >= 2 && mod <= 4 && (mod100 < 12 || mod100 > 14)) return n + ' файла';
|
||
return n + ' файлов';
|
||
}
|
||
|
||
// Drag & drop
|
||
dropzone.addEventListener('click', () => fileInput.click());
|
||
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('dropzone-active'); });
|
||
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dropzone-active'));
|
||
dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('dropzone-active'); processFiles(e.dataTransfer.files); });
|
||
fileInput.addEventListener('change', e => processFiles(e.target.files));
|
||
|
||
function processFiles(files) {
|
||
results.classList.add('hidden');
|
||
errorBlock.classList.add('hidden');
|
||
preview.innerHTML = '';
|
||
preview.classList.add('hidden');
|
||
compressBtn.disabled = true;
|
||
|
||
if (!files.length) return;
|
||
|
||
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||
const valid = Array.from(files).filter(f => validTypes.includes(f.type) && f.size <= 20 * 1024 * 1024);
|
||
|
||
if (!valid.length) {
|
||
showError('Нет подходящих файлов. Допустимы JPEG, PNG, WebP, AVIF, TIFF, GIF, BMP, HEIC до 20 МБ.');
|
||
return;
|
||
}
|
||
|
||
selectedFiles = valid;
|
||
$('fileCount').classList.remove('hidden');
|
||
$('fileCountText').textContent = pluralFiles(valid.length);
|
||
|
||
// Show previews (max 8)
|
||
const shown = valid.slice(0, 8);
|
||
shown.forEach(file => {
|
||
const reader = new FileReader();
|
||
reader.onload = e => {
|
||
const img = document.createElement('img');
|
||
img.src = e.target.result;
|
||
img.className = 'w-full aspect-square object-cover rounded-lg border dark:border-surface-600 border-gray-200';
|
||
preview.appendChild(img);
|
||
preview.classList.remove('hidden');
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
if (valid.length > 8) {
|
||
const more = document.createElement('div');
|
||
more.className = 'w-full aspect-square rounded-lg border dark:border-surface-600 border-gray-200 dark:bg-surface-700 bg-gray-100 flex items-center justify-center text-xs dark:text-gray-500 text-gray-400 font-mono';
|
||
more.textContent = '+' + (valid.length - 8);
|
||
preview.appendChild(more);
|
||
preview.classList.remove('hidden');
|
||
}
|
||
|
||
compressBtn.disabled = false;
|
||
compressBtn.textContent = 'Сжать';
|
||
}
|
||
|
||
function showError(msg) {
|
||
errorBlock.textContent = msg;
|
||
errorBlock.classList.remove('hidden');
|
||
}
|
||
|
||
compressBtn.addEventListener('click', () => {
|
||
if (!selectedFiles || !selectedFiles.length) return;
|
||
|
||
compressBtn.disabled = true;
|
||
compressBtn.textContent = 'Сжимаю...';
|
||
results.classList.add('hidden');
|
||
errorBlock.classList.add('hidden');
|
||
progressBar.hidden = false;
|
||
progressBar.value = 0;
|
||
|
||
const formData = new FormData();
|
||
selectedFiles.forEach(f => formData.append('images', f));
|
||
formData.append('format', $('format').value);
|
||
formData.append('quality', $('quality').value);
|
||
var rv = $('resize').value;
|
||
if (rv === 'custom') rv = prompt('Размер по длинной стороне (px):', '1024') || '0';
|
||
formData.append('resize', rv);
|
||
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('POST', '/compress');
|
||
xhr.responseType = 'json';
|
||
|
||
xhr.upload.onprogress = e => {
|
||
if (e.lengthComputable) progressBar.value = (e.loaded / e.total) * 100;
|
||
};
|
||
|
||
xhr.onload = () => {
|
||
progressBar.hidden = true;
|
||
compressBtn.textContent = 'Сжать';
|
||
compressBtn.disabled = false;
|
||
|
||
if (xhr.status === 200 && xhr.response && xhr.response.success) {
|
||
renderResults(xhr.response);
|
||
} else if (xhr.status === 429) {
|
||
showError('Слишком много запросов. Подождите минуту.');
|
||
} else {
|
||
const msg = xhr.response && xhr.response.error ? xhr.response.error : 'Ошибка при сжатии.';
|
||
showError(msg);
|
||
}
|
||
};
|
||
|
||
xhr.onerror = () => {
|
||
progressBar.hidden = true;
|
||
compressBtn.textContent = 'Сжать';
|
||
compressBtn.disabled = false;
|
||
showError('Ошибка сети. Повторите позже.');
|
||
};
|
||
|
||
xhr.send(formData);
|
||
});
|
||
|
||
function renderResults(data) {
|
||
const { stats, downloadUrl } = data;
|
||
|
||
// Summary
|
||
const totalOrig = stats.reduce((s, f) => s + f.originalSize, 0);
|
||
const totalComp = stats.reduce((s, f) => s + f.compressedSize, 0);
|
||
const totalSavings = totalOrig > 0 ? Math.round((1 - totalComp / totalOrig) * 100) : 0;
|
||
|
||
$('summaryDetail').textContent = `${formatSize(totalOrig)} → ${formatSize(totalComp)}`;
|
||
const pctEl = $('summaryPercent');
|
||
pctEl.textContent = (totalSavings > 0 ? '-' : '') + totalSavings + '%';
|
||
pctEl.className = 'text-lg font-bold font-mono ' + savingsColor(totalSavings);
|
||
|
||
// Table
|
||
const tbody = $('statsBody');
|
||
tbody.innerHTML = '';
|
||
stats.forEach((f, i) => {
|
||
const row = document.createElement('tr');
|
||
row.className = 'border-b dark:border-surface-600/50 border-gray-100 last:border-0' + (i % 2 === 0 ? ' dark:bg-surface-700/20 bg-gray-50/50' : '');
|
||
const sColor = savingsColor(f.savings);
|
||
row.innerHTML = `
|
||
<td class="px-5 py-2.5 font-mono text-xs dark:text-gray-300 text-gray-600 truncate max-w-[180px]" title="${f.outputFilename}">${f.outputFilename}</td>
|
||
<td class="text-right px-3 py-2.5 font-mono text-xs dark:text-gray-500 text-gray-400">${formatSize(f.originalSize)}</td>
|
||
<td class="text-right px-3 py-2.5 font-mono text-xs dark:text-gray-300 text-gray-600">${formatSize(f.compressedSize)}</td>
|
||
<td class="px-5 py-2.5">
|
||
<div class="flex items-center gap-2 justify-end">
|
||
<div class="savings-bar flex-1 max-w-[60px]">
|
||
<div class="savings-bar-fill ${barColor(f.savings)}" style="width: ${Math.max(0, Math.min(100, f.savings))}%"></div>
|
||
</div>
|
||
<span class="font-mono text-xs font-medium ${sColor} w-10 text-right">${f.savings > 0 ? '-' : ''}${f.savings}%</span>
|
||
</div>
|
||
</td>`;
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
// Download
|
||
$('downloadBtn').href = downloadUrl;
|
||
|
||
results.classList.remove('hidden');
|
||
}
|
||
|
||
function savingsColor(pct) {
|
||
if (pct >= 40) return 'text-accent';
|
||
if (pct >= 15) return 'text-accent/70';
|
||
if (pct >= 0) return 'text-warn';
|
||
return 'text-danger';
|
||
}
|
||
|
||
function barColor(pct) {
|
||
if (pct >= 40) return 'bg-accent';
|
||
if (pct >= 15) return 'bg-accent/60';
|
||
if (pct >= 0) return 'bg-warn';
|
||
return 'bg-danger';
|
||
}
|
||
</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>
|