wa-dev-tools/public/compress.html
treamz e3afa5b95a Standardize: shared.css/js, unified sidebar/theme/titles
- Extract shared CSS (sidebar, theme vars, layout) into shared.css
- Extract shared JS (tailwind config, sidebar injection, theme toggle) into shared.js
- Remove ~1200 lines of duplicated code across 13 HTML pages
- Standardize all titles to format: Tool Name — WA Dev Tools
- Standardize page headers (h1 + description) across all tools
- Standardize container padding (tool-container class)
- Sidebar now injected via JS — single source of truth
- Use CSS variables for accent colors everywhere (no more hardcoded #0054e6)
2026-03-21 22:36:59 +03:00

359 lines
17 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" class="dark">
<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"></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">
<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 файлов &middot; до 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-2 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="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>
</select>
</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 до 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('resize', $('resize').value);
formData.append('format', $('format').value);
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>
</body>
</html>