Files
stegasoo/frontends/web/templates/tools.html
Aaron D. Lee 4d8575ce33 Web UI v4.1.6: Admin settings, nav icons, air-gap ready
Admin System Settings page:
- New /admin/settings route with channel key config
- QR code export with tiled print sheet (4x5 on US Letter)
- Server config display (HTTPS, port, auth, DCT/QR status)
- Environment info (version, Python, platform, KDF)

Navigation improvements:
- Icon-only nav with floating labels on hover
- Gold labels slide down below icons
- Gradient pill background on hover

Air-gap ready:
- All vendor libs now local (Bootstrap CSS/JS, Icons, html5-qrcode)
- QRious library for QR generation
- No external CDN dependencies

Other changes:
- Moved About link from nav to footer
- Channel QR export moved from about.html to admin/settings.html
- Print sheet button for QR codes (tiled US Letter output)
- Dev runner script (dev_run.sh) with r/q hotkeys
- Fixed navbar dropdown z-index

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:36:33 -05:00

1122 lines
54 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.
{% extends "base.html" %}
{% block title %}Tools - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-10 col-xl-9">
<div class="card">
<!-- Icon Toolbar Ribbon -->
<div class="tools-ribbon">
<div class="tools-ribbon-group">
<button class="tool-icon-btn active" data-tool="capacity" title="Capacity Calculator">
<i class="bi bi-rulers"></i>
<span>Capacity</span>
</button>
<button class="tool-icon-btn" data-tool="exif" title="EXIF Viewer">
<i class="bi bi-card-text"></i>
<span>EXIF</span>
</button>
</div>
<div class="tools-ribbon-divider"></div>
<div class="tools-ribbon-group">
<button class="tool-icon-btn" data-tool="strip" title="Strip Metadata">
<i class="bi bi-eraser"></i>
<span>Strip</span>
</button>
<button class="tool-icon-btn" data-tool="rotate" title="Rotate / Flip">
<i class="bi bi-arrow-repeat"></i>
<span>Rotate</span>
</button>
<button class="tool-icon-btn" data-tool="compress" title="JPEG Compression">
<i class="bi bi-file-zip"></i>
<span>Compress</span>
</button>
<button class="tool-icon-btn" data-tool="convert" title="Format Convert">
<i class="bi bi-arrow-left-right"></i>
<span>Convert</span>
</button>
</div>
</div>
<!-- Two-Panel Layout -->
<div class="tools-panels">
<!-- Left Panel - Input/Dropzone -->
<div class="tools-panel-input">
<!-- ============================================================ -->
<!-- CAPACITY CALCULATOR -->
<!-- ============================================================ -->
<div class="tool-section active" id="capacitySection">
<div class="tool-options">
<!-- No options for capacity -->
</div>
<div class="tool-dropzone" id="capacityZone">
<input type="file" accept="image/*" id="capacityFile">
<div class="tool-dropzone-label">
<i class="bi bi-image"></i>
<span>Drop image or click to browse</span>
</div>
<div class="tool-dropzone-preview">
<img id="capacityThumb" alt="Preview">
<div class="file-name" id="capacityFileName">image.jpg</div>
<div class="file-meta" id="capacityFileMeta">--</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary tool-dropzone-clear d-none" id="capacityClear">
<i class="bi bi-x"></i>
</button>
</div>
</div>
<!-- ============================================================ -->
<!-- EXIF VIEWER -->
<!-- ============================================================ -->
<div class="tool-section" id="exifSection">
<div class="tool-options">
<!-- No options for EXIF view -->
</div>
<div class="tool-dropzone" id="exifZone">
<input type="file" accept="image/*" id="exifFile">
<div class="tool-dropzone-label">
<i class="bi bi-card-image"></i>
<span>Drop image or click to browse</span>
</div>
<div class="tool-dropzone-preview">
<img id="exifThumb" alt="Preview">
<div class="file-name" id="exifFileName">image.jpg</div>
<div class="file-meta" id="exifFileMeta">--</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary tool-dropzone-clear d-none" id="exifClear">
<i class="bi bi-x"></i>
</button>
</div>
</div>
<!-- ============================================================ -->
<!-- STRIP METADATA -->
<!-- ============================================================ -->
<div class="tool-section" id="stripSection">
<div class="tool-options">
<label class="me-2">Output:</label>
<select class="form-select form-select-sm" id="stripFormat" style="width: auto;">
<option value="PNG" selected>PNG (lossless)</option>
<option value="JPEG">JPEG</option>
</select>
</div>
<div class="tool-dropzone" id="stripZone">
<input type="file" accept="image/*" id="stripFile">
<div class="tool-dropzone-label">
<i class="bi bi-file-earmark-x"></i>
<span>Drop image or click to browse</span>
</div>
<div class="tool-dropzone-preview">
<img id="stripThumb" alt="Preview">
<div class="file-name" id="stripFileName">image.jpg</div>
<div class="file-meta" id="stripFileMeta">--</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary tool-dropzone-clear d-none" id="stripClear">
<i class="bi bi-x"></i>
</button>
</div>
</div>
<!-- ============================================================ -->
<!-- ROTATE / FLIP -->
<!-- ============================================================ -->
<div class="tool-section" id="rotateSection">
<div class="tool-options">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary" id="rotateLeft" title="Rotate 90° Left">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
<button type="button" class="btn btn-outline-secondary" id="rotateRight" title="Rotate 90° Right">
<i class="bi bi-arrow-clockwise"></i>
</button>
<button type="button" class="btn btn-outline-secondary" id="flipH" title="Flip Horizontal">
<i class="bi bi-symmetry-vertical"></i>
</button>
<button type="button" class="btn btn-outline-secondary" id="flipV" title="Flip Vertical">
<i class="bi bi-symmetry-horizontal"></i>
</button>
</div>
</div>
<div class="tool-dropzone" id="rotateZone">
<input type="file" accept="image/*" id="rotateFile">
<div class="tool-dropzone-label">
<i class="bi bi-arrow-repeat"></i>
<span>Drop image or click to browse</span>
</div>
<div class="tool-dropzone-preview">
<div class="rotate-img-container">
<img id="rotateThumb" alt="Preview">
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary tool-dropzone-clear d-none" id="rotateClear">
<i class="bi bi-x"></i>
</button>
</div>
<!-- File info separate from dropzone to avoid rotation overlap -->
<div class="rotate-file-info d-none" id="rotateFileInfo">
<div class="file-name" id="rotateFileName">image.jpg</div>
<div class="file-meta" id="rotateFileMeta">--</div>
</div>
</div>
<!-- ============================================================ -->
<!-- JPEG COMPRESSION TESTER -->
<!-- ============================================================ -->
<div class="tool-section" id="compressSection">
<div class="tool-options">
<label class="me-2">Quality:</label>
<input type="range" class="form-range" id="compressQuality" min="10" max="100" value="85" style="width: 120px;">
<span class="ms-2 small text-muted" id="compressQualityVal">85%</span>
</div>
<div class="tool-dropzone" id="compressZone">
<input type="file" accept="image/*" id="compressFile">
<div class="tool-dropzone-label">
<i class="bi bi-file-zip"></i>
<span>Drop image or click to browse</span>
</div>
<div class="tool-dropzone-preview">
<img id="compressThumb" alt="Preview">
<div class="file-name" id="compressFileName">image.jpg</div>
<div class="file-meta" id="compressFileMeta">--</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary tool-dropzone-clear d-none" id="compressClear">
<i class="bi bi-x"></i>
</button>
</div>
</div>
<!-- ============================================================ -->
<!-- FORMAT CONVERT -->
<!-- ============================================================ -->
<div class="tool-section" id="convertSection">
<div class="tool-options">
<label class="me-2">Convert to:</label>
<select class="form-select form-select-sm" id="convertFormat" style="width: auto;">
<option value="PNG">PNG</option>
<option value="JPEG">JPEG</option>
<option value="WEBP">WebP</option>
</select>
<label class="ms-3 me-2" id="convertQualityLabel">Quality:</label>
<input type="range" class="form-range" id="convertQuality" min="10" max="100" value="90" style="width: 80px;">
<span class="ms-2 small text-muted" id="convertQualityVal">90%</span>
</div>
<div class="tool-dropzone" id="convertZone">
<input type="file" accept="image/*" id="convertFile">
<div class="tool-dropzone-label">
<i class="bi bi-arrow-left-right"></i>
<span>Drop image or click to browse</span>
</div>
<div class="tool-dropzone-preview">
<img id="convertThumb" alt="Preview">
<div class="file-name" id="convertFileName">image.jpg</div>
<div class="file-meta" id="convertFileMeta">--</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary tool-dropzone-clear d-none" id="convertClear">
<i class="bi bi-x"></i>
</button>
</div>
</div>
<!-- Tool Mode Banner - pushed to bottom via flexbox -->
<div class="tool-mode-banner mode-analyze" id="toolModeBanner">
<span class="tool-mode-type" id="toolModeType">Analyze</span>
<span class="tool-mode-name" id="toolModeName">Capacity Calculator</span>
</div>
</div>
<!-- Right Panel - Results -->
<div class="tools-panel-results">
<!-- ============================================================ -->
<!-- CAPACITY RESULTS -->
<!-- ============================================================ -->
<div class="tool-section active" id="capacityResults">
<div class="tool-results-header">
<h6><i class="bi bi-rulers me-2"></i>Capacity Calculator</h6>
<small>Check how much data can be hidden</small>
</div>
<div class="tool-results-body">
<div class="tool-results-empty" id="capacityEmpty">
<i class="bi bi-image"></i>
<span>Drop an image to analyze</span>
</div>
<div id="capacityData" class="d-none">
<div class="tool-result-item">
<span class="tool-result-label">Dimensions</span>
<span class="tool-result-value" id="capDimensions">--</span>
</div>
<div class="tool-result-item">
<span class="tool-result-label">Megapixels</span>
<span class="tool-result-value" id="capMegapixels">--</span>
</div>
<div class="tool-result-item">
<span class="tool-result-label">LSB Capacity</span>
<span class="tool-result-value text-success" id="capLsb">--</span>
</div>
<div class="tool-result-item">
<span class="tool-result-label">DCT Capacity</span>
<span class="tool-result-value text-warning" id="capDct">--</span>
</div>
</div>
</div>
<div class="tool-results-actions d-none" id="capacityActions">
<button type="button" class="btn btn-outline-secondary btn-sm" id="capacityClearBtn">
<i class="bi bi-x-circle me-1"></i>Clear
</button>
</div>
</div>
<!-- ============================================================ -->
<!-- EXIF RESULTS -->
<!-- ============================================================ -->
<div class="tool-section" id="exifResults">
<div class="tool-results-header">
<h6><i class="bi bi-card-text me-2"></i>EXIF Viewer</h6>
<small>View image metadata</small>
</div>
<div class="tool-results-body">
<div class="tool-results-empty" id="exifEmpty">
<i class="bi bi-card-image"></i>
<span>Drop an image to view metadata</span>
</div>
<div id="exifData" class="d-none">
<div class="tool-exif-table">
<table>
<tbody id="exifTable"></tbody>
</table>
</div>
<div id="exifNoData" class="text-muted text-center py-3 d-none">
<i class="bi bi-inbox d-block mb-2"></i>
No metadata found
</div>
</div>
</div>
<div class="tool-results-actions d-none" id="exifActions">
<button type="button" class="btn btn-outline-danger btn-sm" id="exifClearAll">
<i class="bi bi-trash me-1"></i>Clear All
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="exifClearBtn">
<i class="bi bi-x-circle me-1"></i>Clear
</button>
</div>
</div>
<!-- ============================================================ -->
<!-- STRIP RESULTS -->
<!-- ============================================================ -->
<div class="tool-section" id="stripResults">
<div class="tool-results-header">
<h6><i class="bi bi-eraser me-2"></i>Strip Metadata</h6>
<small>Remove all EXIF data</small>
</div>
<div class="tool-results-body">
<div class="tool-results-empty" id="stripEmpty">
<i class="bi bi-file-earmark-x"></i>
<span>Drop an image to strip</span>
</div>
<div id="stripData" class="d-none">
<div class="tool-result-item">
<span class="tool-result-label">Original Size</span>
<span class="tool-result-value" id="stripOrigSize">--</span>
</div>
<div class="tool-result-item">
<span class="tool-result-label">Output Format</span>
<span class="tool-result-value" id="stripOutFormat">PNG</span>
</div>
<div class="alert alert-info small mt-3 mb-0">
<i class="bi bi-info-circle me-1"></i>
All metadata will be removed from the image.
</div>
</div>
</div>
<div class="tool-results-actions d-none" id="stripActions">
<button type="button" class="btn btn-danger btn-sm" id="stripAction">
<i class="bi bi-download me-1"></i>Strip & Download
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="stripClearBtn">
<i class="bi bi-x-circle me-1"></i>Clear
</button>
</div>
</div>
<!-- ============================================================ -->
<!-- ROTATE RESULTS -->
<!-- ============================================================ -->
<div class="tool-section" id="rotateResults">
<div class="tool-results-header">
<h6><i class="bi bi-arrow-repeat me-2"></i>Rotate / Flip</h6>
<small>Transform image orientation</small>
</div>
<div class="tool-results-body">
<div class="tool-results-empty" id="rotateEmpty">
<i class="bi bi-arrow-repeat"></i>
<span>Drop an image to transform</span>
</div>
<div id="rotateData" class="d-none">
<div class="tool-result-item">
<span class="tool-result-label">Original</span>
<span class="tool-result-value" id="rotateOrigDims">--</span>
</div>
<div class="tool-result-item">
<span class="tool-result-label">Rotation</span>
<span class="tool-result-value" id="rotateAngle"></span>
</div>
<div class="tool-result-item">
<span class="tool-result-label">Flipped</span>
<span class="tool-result-value" id="rotateFlip">None</span>
</div>
</div>
</div>
<div class="tool-results-actions d-none" id="rotateActions">
<button type="button" class="btn btn-primary btn-sm" id="rotateDownload">
<i class="bi bi-download me-1"></i>Download
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="rotateClearBtn">
<i class="bi bi-x-circle me-1"></i>Clear
</button>
</div>
</div>
<!-- ============================================================ -->
<!-- COMPRESS RESULTS -->
<!-- ============================================================ -->
<div class="tool-section" id="compressResults">
<div class="tool-results-header">
<h6><i class="bi bi-file-zip me-2"></i>JPEG Compression</h6>
<small>Test compression quality</small>
</div>
<div class="tool-results-body">
<div class="tool-results-empty" id="compressEmpty">
<i class="bi bi-file-zip"></i>
<span>Drop an image to compress</span>
</div>
<div id="compressData" class="d-none">
<div class="tool-result-item">
<span class="tool-result-label">Original</span>
<span class="tool-result-value" id="compressOrigSize">--</span>
</div>
<div class="tool-result-item">
<span class="tool-result-label">Compressed</span>
<span class="tool-result-value text-success" id="compressNewSize">--</span>
</div>
<div class="tool-result-item">
<span class="tool-result-label">Reduction</span>
<span class="tool-result-value text-warning" id="compressReduction">--</span>
</div>
</div>
</div>
<div class="tool-results-actions d-none" id="compressActions">
<button type="button" class="btn btn-primary btn-sm" id="compressDownload">
<i class="bi bi-download me-1"></i>Download
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="compressClearBtn">
<i class="bi bi-x-circle me-1"></i>Clear
</button>
</div>
</div>
<!-- ============================================================ -->
<!-- CONVERT RESULTS -->
<!-- ============================================================ -->
<div class="tool-section" id="convertResults">
<div class="tool-results-header">
<h6><i class="bi bi-arrow-left-right me-2"></i>Format Convert</h6>
<small>Convert between formats</small>
</div>
<div class="tool-results-body">
<div class="tool-results-empty" id="convertEmpty">
<i class="bi bi-arrow-left-right"></i>
<span>Drop an image to convert</span>
</div>
<div id="convertData" class="d-none">
<div class="tool-result-item">
<span class="tool-result-label">Original</span>
<span class="tool-result-value" id="convertOrigInfo">--</span>
</div>
<div class="tool-result-item">
<span class="tool-result-label">Output</span>
<span class="tool-result-value text-primary" id="convertOutFormat">PNG</span>
</div>
<div class="tool-result-item">
<span class="tool-result-label">New Size</span>
<span class="tool-result-value" id="convertNewSize">--</span>
</div>
</div>
</div>
<div class="tool-results-actions d-none" id="convertActions">
<button type="button" class="btn btn-primary btn-sm" id="convertDownload">
<i class="bi bi-download me-1"></i>Download
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="convertClearBtn">
<i class="bi bi-x-circle me-1"></i>Clear
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ============================================================================
// TOOL SWITCHING
// ============================================================================
const toolButtons = document.querySelectorAll('.tool-icon-btn');
const toolSections = {
capacity: { input: document.getElementById('capacitySection'), results: document.getElementById('capacityResults') },
exif: { input: document.getElementById('exifSection'), results: document.getElementById('exifResults') },
strip: { input: document.getElementById('stripSection'), results: document.getElementById('stripResults') },
rotate: { input: document.getElementById('rotateSection'), results: document.getElementById('rotateResults') },
compress: { input: document.getElementById('compressSection'), results: document.getElementById('compressResults') },
convert: { input: document.getElementById('convertSection'), results: document.getElementById('convertResults') }
};
const toolMeta = {
capacity: { type: 'Analyze', name: 'Capacity Calculator' },
exif: { type: 'Analyze', name: 'EXIF Viewer' },
strip: { type: 'Transform', name: 'Strip Metadata' },
rotate: { type: 'Transform', name: 'Rotate / Flip' },
compress: { type: 'Transform', name: 'JPEG Compression' },
convert: { type: 'Transform', name: 'Format Convert' }
};
function switchTool(toolName) {
// Update button states
toolButtons.forEach(btn => btn.classList.remove('active'));
document.querySelector(`[data-tool="${toolName}"]`)?.classList.add('active');
// Update section visibility
Object.entries(toolSections).forEach(([key, sections]) => {
const isActive = key === toolName;
sections.input.classList.toggle('active', isActive);
sections.results.classList.toggle('active', isActive);
});
// Update mode banner
const meta = toolMeta[toolName];
if (meta) {
const banner = document.getElementById('toolModeBanner');
const typeEl = document.getElementById('toolModeType');
const nameEl = document.getElementById('toolModeName');
typeEl.textContent = meta.type;
nameEl.textContent = meta.name;
banner.classList.remove('mode-analyze', 'mode-transform');
banner.classList.add(meta.type === 'Analyze' ? 'mode-analyze' : 'mode-transform');
}
}
toolButtons.forEach(btn => {
btn.addEventListener('click', () => switchTool(btn.dataset.tool));
});
// ============================================================================
// SHARED HELPERS
// ============================================================================
function setupDropZone(zoneId, fileInputId, onFile) {
const zone = document.getElementById(zoneId);
const input = document.getElementById(fileInputId);
if (!zone || !input) return;
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('drag-over');
if (e.dataTransfer.files[0]) {
input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change'));
}
});
input.addEventListener('change', function() {
if (this.files[0]) onFile(this.files[0]);
});
}
function showPreview(zoneId, file, thumbId, nameId, metaId, clearBtnId) {
const zone = document.getElementById(zoneId);
const thumb = document.getElementById(thumbId);
const name = document.getElementById(nameId);
const meta = document.getElementById(metaId);
const clearBtn = document.getElementById(clearBtnId);
zone.classList.add('has-file');
name.textContent = file.name;
if (meta) meta.textContent = formatBytes(file.size);
const reader = new FileReader();
reader.onload = e => thumb.src = e.target.result;
reader.readAsDataURL(file);
clearBtn?.classList.remove('d-none');
}
function clearDropZone(zoneId, fileInputId, clearBtnId, extraCleanup) {
const zone = document.getElementById(zoneId);
const input = document.getElementById(fileInputId);
const clearBtn = document.getElementById(clearBtnId);
zone?.classList.remove('has-file');
if (input) input.value = '';
clearBtn?.classList.add('d-none');
if (extraCleanup) extraCleanup();
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ============================================================================
// CAPACITY CALCULATOR
// ============================================================================
setupDropZone('capacityZone', 'capacityFile', async (file) => {
showPreview('capacityZone', file, 'capacityThumb', 'capacityFileName', 'capacityFileMeta', 'capacityClear');
const formData = new FormData();
formData.append('image', file);
try {
const res = await fetch('/api/tools/capacity', { method: 'POST', body: formData });
const data = await res.json();
if (data.success) {
document.getElementById('capDimensions').textContent = `${data.width} × ${data.height}`;
document.getElementById('capMegapixels').textContent = data.megapixels + ' MP';
document.getElementById('capLsb').textContent = data.lsb.capacity_kb.toFixed(1) + ' KB';
document.getElementById('capDct').textContent = data.dct.available
? data.dct.capacity_kb.toFixed(1) + ' KB'
: 'N/A';
document.getElementById('capacityEmpty').classList.add('d-none');
document.getElementById('capacityData').classList.remove('d-none');
document.getElementById('capacityActions').classList.remove('d-none');
}
} catch (err) {
console.error(err);
}
});
document.getElementById('capacityClear')?.addEventListener('click', clearCapacity);
document.getElementById('capacityClearBtn')?.addEventListener('click', clearCapacity);
function clearCapacity() {
clearDropZone('capacityZone', 'capacityFile', 'capacityClear', () => {
document.getElementById('capacityEmpty').classList.remove('d-none');
document.getElementById('capacityData').classList.add('d-none');
document.getElementById('capacityActions').classList.add('d-none');
});
}
// ============================================================================
// EXIF VIEWER
// ============================================================================
let exifCurrentFile = null;
setupDropZone('exifZone', 'exifFile', async (file) => {
exifCurrentFile = file;
showPreview('exifZone', file, 'exifThumb', 'exifFileName', 'exifFileMeta', 'exifClear');
const formData = new FormData();
formData.append('image', file);
try {
const res = await fetch('/api/tools/exif', { method: 'POST', body: formData });
const data = await res.json();
if (data.success) {
const tbody = document.getElementById('exifTable');
const entries = Object.entries(data.exif).sort((a, b) => a[0].localeCompare(b[0]));
if (entries.length === 0) {
tbody.innerHTML = '';
document.getElementById('exifNoData').classList.remove('d-none');
} else {
document.getElementById('exifNoData').classList.add('d-none');
tbody.innerHTML = entries.map(([key, value]) => {
let displayVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
if (displayVal.length > 40) displayVal = displayVal.substring(0, 37) + '...';
return `<tr><th>${key}</th><td title="${String(value)}">${displayVal}</td></tr>`;
}).join('');
}
document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none');
document.getElementById('exifActions').classList.remove('d-none');
}
} catch (err) {
console.error(err);
}
});
document.getElementById('exifClear')?.addEventListener('click', clearExif);
document.getElementById('exifClearBtn')?.addEventListener('click', clearExif);
function clearExif() {
clearDropZone('exifZone', 'exifFile', 'exifClear', () => {
document.getElementById('exifEmpty').classList.remove('d-none');
document.getElementById('exifData').classList.add('d-none');
document.getElementById('exifActions').classList.add('d-none');
exifCurrentFile = null;
});
}
document.getElementById('exifClearAll')?.addEventListener('click', async function() {
if (!exifCurrentFile) return;
if (!confirm('Remove all metadata and download clean image?')) return;
const formData = new FormData();
formData.append('image', exifCurrentFile);
formData.append('format', 'PNG');
const btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>...';
try {
const res = await fetch('/api/tools/exif/clear', { method: 'POST', body: formData });
if (res.ok) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || 'clean.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} catch (err) {
console.error(err);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash me-1"></i>Clear All';
}
});
// ============================================================================
// STRIP METADATA
// ============================================================================
let stripCurrentFile = null;
setupDropZone('stripZone', 'stripFile', (file) => {
stripCurrentFile = file;
showPreview('stripZone', file, 'stripThumb', 'stripFileName', 'stripFileMeta', 'stripClear');
document.getElementById('stripOrigSize').textContent = formatBytes(file.size);
document.getElementById('stripOutFormat').textContent = document.getElementById('stripFormat').value;
document.getElementById('stripEmpty').classList.add('d-none');
document.getElementById('stripData').classList.remove('d-none');
document.getElementById('stripActions').classList.remove('d-none');
});
document.getElementById('stripFormat')?.addEventListener('change', function() {
document.getElementById('stripOutFormat').textContent = this.value;
});
document.getElementById('stripClear')?.addEventListener('click', clearStrip);
document.getElementById('stripClearBtn')?.addEventListener('click', clearStrip);
function clearStrip() {
clearDropZone('stripZone', 'stripFile', 'stripClear', () => {
document.getElementById('stripEmpty').classList.remove('d-none');
document.getElementById('stripData').classList.add('d-none');
document.getElementById('stripActions').classList.add('d-none');
stripCurrentFile = null;
});
}
document.getElementById('stripAction')?.addEventListener('click', async function() {
if (!stripCurrentFile) return;
const format = document.getElementById('stripFormat').value;
const formData = new FormData();
formData.append('image', stripCurrentFile);
formData.append('format', format);
const btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Processing...';
try {
const res = await fetch('/api/tools/exif/clear', { method: 'POST', body: formData });
if (res.ok) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || `clean.${format.toLowerCase()}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} catch (err) {
console.error(err);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-download me-1"></i>Strip & Download';
}
});
// ============================================================================
// ROTATE / FLIP (CSS transforms for instant preview)
// ============================================================================
let rotateCurrentFile = null;
let rotateState = { rotation: 0, flipH: false, flipV: false };
let rotateImgDims = { width: 0, height: 0 }; // Original image dimensions
const ROTATE_MAX_WIDTH = 280; // Max preview width
const ROTATE_MAX_HEIGHT = 180; // Max preview height
setupDropZone('rotateZone', 'rotateFile', async (file) => {
rotateCurrentFile = file;
rotateState = { rotation: 0, flipH: false, flipV: false };
rotateImgDims = { width: 0, height: 0 };
// Show UI immediately
document.getElementById('rotateAngle').textContent = '0°';
document.getElementById('rotateFlip').textContent = 'None';
document.getElementById('rotateOrigDims').textContent = '...';
document.getElementById('rotateEmpty').classList.add('d-none');
document.getElementById('rotateData').classList.remove('d-none');
document.getElementById('rotateActions').classList.remove('d-none');
// Load image to get dimensions, then show preview
const thumb = document.getElementById('rotateThumb');
const objectUrl = URL.createObjectURL(file);
thumb.onload = () => {
rotateImgDims = { width: thumb.naturalWidth, height: thumb.naturalHeight };
document.getElementById('rotateOrigDims').textContent = `${rotateImgDims.width} × ${rotateImgDims.height}`;
applyRotateCSSTransform();
};
thumb.src = objectUrl;
// Show the preview area and file info
const zone = document.getElementById('rotateZone');
zone.classList.add('has-file');
document.getElementById('rotateFileName').textContent = file.name;
document.getElementById('rotateFileMeta').textContent = formatBytes(file.size);
document.getElementById('rotateFileInfo').classList.remove('d-none');
document.getElementById('rotateClear').classList.remove('d-none');
});
function updateRotateDisplay() {
document.getElementById('rotateAngle').textContent = rotateState.rotation + '°';
const flips = [];
if (rotateState.flipH) flips.push('H');
if (rotateState.flipV) flips.push('V');
document.getElementById('rotateFlip').textContent = flips.length ? flips.join(' + ') : 'None';
}
function applyRotateCSSTransform() {
const thumb = document.getElementById('rotateThumb');
const scaleX = rotateState.flipH ? -1 : 1;
const scaleY = rotateState.flipV ? -1 : 1;
// Calculate effective dimensions after rotation
const isSideways = rotateState.rotation === 90 || rotateState.rotation === 270;
const effectiveW = isSideways ? rotateImgDims.height : rotateImgDims.width;
const effectiveH = isSideways ? rotateImgDims.width : rotateImgDims.height;
// Calculate scale to fit within max bounds
let displayW, displayH;
if (effectiveW > 0 && effectiveH > 0) {
const scaleToFit = Math.min(ROTATE_MAX_WIDTH / effectiveW, ROTATE_MAX_HEIGHT / effectiveH, 1);
displayW = effectiveW * scaleToFit;
displayH = effectiveH * scaleToFit;
// For sideways rotation, we need to set the img size to what it will appear as after rotation
if (isSideways) {
// Image is rotated, so we swap back for the actual img element sizing
thumb.style.width = displayH + 'px';
thumb.style.height = displayW + 'px';
} else {
thumb.style.width = displayW + 'px';
thumb.style.height = displayH + 'px';
}
}
// Apply transform
thumb.style.transform = `rotate(${rotateState.rotation}deg) scale(${scaleX}, ${scaleY})`;
}
document.getElementById('rotateLeft')?.addEventListener('click', () => {
rotateState.rotation = (rotateState.rotation - 90 + 360) % 360;
updateRotateDisplay();
applyRotateCSSTransform();
});
document.getElementById('rotateRight')?.addEventListener('click', () => {
rotateState.rotation = (rotateState.rotation + 90) % 360;
updateRotateDisplay();
applyRotateCSSTransform();
});
document.getElementById('flipH')?.addEventListener('click', () => {
rotateState.flipH = !rotateState.flipH;
updateRotateDisplay();
applyRotateCSSTransform();
});
document.getElementById('flipV')?.addEventListener('click', () => {
rotateState.flipV = !rotateState.flipV;
updateRotateDisplay();
applyRotateCSSTransform();
});
document.getElementById('rotateClear')?.addEventListener('click', clearRotate);
document.getElementById('rotateClearBtn')?.addEventListener('click', clearRotate);
function clearRotate() {
clearDropZone('rotateZone', 'rotateFile', 'rotateClear', () => {
document.getElementById('rotateEmpty').classList.remove('d-none');
document.getElementById('rotateData').classList.add('d-none');
document.getElementById('rotateActions').classList.add('d-none');
document.getElementById('rotateFileInfo').classList.add('d-none');
const thumb = document.getElementById('rotateThumb');
thumb.style.transform = '';
thumb.style.width = '';
thumb.style.height = '';
rotateCurrentFile = null;
rotateState = { rotation: 0, flipH: false, flipV: false };
rotateImgDims = { width: 0, height: 0 };
});
}
document.getElementById('rotateDownload')?.addEventListener('click', async function() {
if (!rotateCurrentFile) return;
const btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>...';
// Use server for high-quality PNG output
const formData = new FormData();
formData.append('image', rotateCurrentFile);
formData.append('rotation', rotateState.rotation);
formData.append('flip_h', rotateState.flipH);
formData.append('flip_v', rotateState.flipV);
try {
const res = await fetch('/api/tools/rotate', { method: 'POST', body: formData });
if (res.ok) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const baseName = rotateCurrentFile?.name?.replace(/\.[^.]+$/, '') || 'rotated';
a.download = `${baseName}_transformed.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} catch (err) {
console.error(err);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-download me-1"></i>Download';
}
});
// ============================================================================
// JPEG COMPRESSION TESTER
// ============================================================================
let compressCurrentFile = null;
let compressResultBlob = null;
let compressDebounce = null;
// Quality slider display update
document.getElementById('compressQuality')?.addEventListener('input', function() {
document.getElementById('compressQualityVal').textContent = this.value + '%';
// Debounce the compression
clearTimeout(compressDebounce);
compressDebounce = setTimeout(() => applyCompression(), 300);
});
setupDropZone('compressZone', 'compressFile', async (file) => {
compressCurrentFile = file;
showPreview('compressZone', file, 'compressThumb', 'compressFileName', 'compressFileMeta', 'compressClear');
document.getElementById('compressOrigSize').textContent = formatBytes(file.size);
document.getElementById('compressEmpty').classList.add('d-none');
document.getElementById('compressData').classList.remove('d-none');
document.getElementById('compressActions').classList.remove('d-none');
// Apply initial compression
applyCompression();
});
async function applyCompression() {
if (!compressCurrentFile) return;
const quality = parseInt(document.getElementById('compressQuality').value);
const formData = new FormData();
formData.append('image', compressCurrentFile);
formData.append('quality', quality);
try {
const res = await fetch('/api/tools/compress', { method: 'POST', body: formData });
if (res.ok) {
compressResultBlob = await res.blob();
const newSize = compressResultBlob.size;
const origSize = compressCurrentFile.size;
const reduction = ((origSize - newSize) / origSize * 100).toFixed(1);
document.getElementById('compressNewSize').textContent = formatBytes(newSize);
document.getElementById('compressReduction').textContent =
reduction > 0 ? `-${reduction}%` : `+${Math.abs(reduction)}%`;
document.getElementById('compressReduction').className =
'tool-result-value ' + (reduction > 0 ? 'text-success' : 'text-danger');
// Update preview
document.getElementById('compressThumb').src = URL.createObjectURL(compressResultBlob);
}
} catch (err) {
console.error(err);
}
}
document.getElementById('compressClear')?.addEventListener('click', clearCompress);
document.getElementById('compressClearBtn')?.addEventListener('click', clearCompress);
function clearCompress() {
clearDropZone('compressZone', 'compressFile', 'compressClear', () => {
document.getElementById('compressEmpty').classList.remove('d-none');
document.getElementById('compressData').classList.add('d-none');
document.getElementById('compressActions').classList.add('d-none');
compressCurrentFile = null;
compressResultBlob = null;
});
}
document.getElementById('compressDownload')?.addEventListener('click', () => {
if (!compressResultBlob) return;
const url = URL.createObjectURL(compressResultBlob);
const a = document.createElement('a');
a.href = url;
const baseName = compressCurrentFile?.name?.replace(/\.[^.]+$/, '') || 'compressed';
const quality = document.getElementById('compressQuality').value;
a.download = `${baseName}_q${quality}.jpg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
// ============================================================================
// FORMAT CONVERT
// ============================================================================
let convertCurrentFile = null;
let convertResultBlob = null;
// Quality controls visibility
document.getElementById('convertFormat')?.addEventListener('change', function() {
const format = this.value;
const qualityLabel = document.getElementById('convertQualityLabel');
const qualitySlider = document.getElementById('convertQuality');
const qualityVal = document.getElementById('convertQualityVal');
// Quality only applies to JPEG and WebP
const showQuality = format === 'JPEG' || format === 'WEBP';
qualityLabel.style.display = showQuality ? '' : 'none';
qualitySlider.style.display = showQuality ? '' : 'none';
qualityVal.style.display = showQuality ? '' : 'none';
// Update result panel
document.getElementById('convertOutFormat').textContent = format;
});
document.getElementById('convertQuality')?.addEventListener('input', function() {
document.getElementById('convertQualityVal').textContent = this.value + '%';
});
setupDropZone('convertZone', 'convertFile', async (file) => {
convertCurrentFile = file;
showPreview('convertZone', file, 'convertThumb', 'convertFileName', 'convertFileMeta', 'convertClear');
// Detect original format from extension
const ext = file.name.split('.').pop()?.toUpperCase() || '?';
document.getElementById('convertOrigInfo').textContent = `${ext} · ${formatBytes(file.size)}`;
document.getElementById('convertOutFormat').textContent = document.getElementById('convertFormat').value;
document.getElementById('convertNewSize').textContent = '--';
document.getElementById('convertEmpty').classList.add('d-none');
document.getElementById('convertData').classList.remove('d-none');
document.getElementById('convertActions').classList.remove('d-none');
});
document.getElementById('convertClear')?.addEventListener('click', clearConvert);
document.getElementById('convertClearBtn')?.addEventListener('click', clearConvert);
function clearConvert() {
clearDropZone('convertZone', 'convertFile', 'convertClear', () => {
document.getElementById('convertEmpty').classList.remove('d-none');
document.getElementById('convertData').classList.add('d-none');
document.getElementById('convertActions').classList.add('d-none');
convertCurrentFile = null;
convertResultBlob = null;
});
}
document.getElementById('convertDownload')?.addEventListener('click', async function() {
if (!convertCurrentFile) return;
const format = document.getElementById('convertFormat').value;
const quality = parseInt(document.getElementById('convertQuality').value);
const btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Converting...';
const formData = new FormData();
formData.append('image', convertCurrentFile);
formData.append('format', format);
formData.append('quality', quality);
try {
const res = await fetch('/api/tools/convert', { method: 'POST', body: formData });
if (res.ok) {
convertResultBlob = await res.blob();
document.getElementById('convertNewSize').textContent = formatBytes(convertResultBlob.size);
// Download
const url = URL.createObjectURL(convertResultBlob);
const a = document.createElement('a');
a.href = url;
const baseName = convertCurrentFile?.name?.replace(/\.[^.]+$/, '') || 'converted';
a.download = `${baseName}.${format.toLowerCase()}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} catch (err) {
console.error(err);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-download me-1"></i>Download';
}
});
</script>
{% endblock %}