Complete project rebrand for better positioning in the press freedom and digital security space. FieldWitness communicates both field deployment and evidence testimony — appropriate for the target audience of journalists, NGOs, and human rights organizations. Rename mapping: - soosef → fieldwitness (package, CLI, all imports) - soosef.stegasoo → fieldwitness.stego - soosef.verisoo → fieldwitness.attest - ~/.soosef/ → ~/.fwmetadata/ (innocuous data dir name) - SOOSEF_DATA_DIR → FIELDWITNESS_DATA_DIR - SoosefConfig → FieldWitnessConfig - SoosefError → FieldWitnessError Also includes: - License switch from MIT to GPL-3.0 - C2PA bridge module (Phase 0-2 MVP): cert.py, export.py, vendor_assertions.py - README repositioned to lead with provenance/federation, stego backgrounded - Threat model skeleton at docs/security/threat-model.md - Planning docs: docs/planning/c2pa-integration.md, docs/planning/gtm-feasibility.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1208 lines
60 KiB
HTML
1208 lines
60 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Tools - Stego{% 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="compress" title="JPEG Compression">
|
||
<i class="bi bi-file-zip"></i>
|
||
<span>Compress</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="strip" title="Strip Metadata">
|
||
<i class="bi bi-eraser"></i>
|
||
<span>Strip</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="exif-grid" id="exifGrid">
|
||
<!-- Cards populated by JS -->
|
||
</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">0°</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 class="alert alert-success small mt-3 mb-0" id="rotateJpegSafe" style="display: none;">
|
||
<i class="bi bi-check-circle me-1"></i>
|
||
<strong>DCT Safe:</strong> Uses jpegtran for lossless JPEG rotation. Your stego data will be preserved.
|
||
</div>
|
||
<div class="alert alert-warning small mt-3 mb-0" id="rotateNonJpegWarn" style="display: none;">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||
<strong>Note:</strong> Non-JPEG images are re-encoded during rotation.
|
||
</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 });
|
||
|
||
// Check for auth redirect or non-JSON response
|
||
const contentType = res.headers.get('content-type') || '';
|
||
if (!contentType.includes('application/json')) {
|
||
console.error('EXIF API returned non-JSON:', res.status, contentType);
|
||
document.getElementById('exifNoData').classList.remove('d-none');
|
||
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-exclamation-triangle d-block mb-2"></i>Session expired - please refresh';
|
||
document.getElementById('exifEmpty').classList.add('d-none');
|
||
document.getElementById('exifData').classList.remove('d-none');
|
||
return;
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
const grid = document.getElementById('exifGrid');
|
||
const entries = Object.entries(data.exif);
|
||
|
||
if (entries.length === 0) {
|
||
grid.innerHTML = '';
|
||
document.getElementById('exifNoData').classList.remove('d-none');
|
||
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-inbox d-block mb-2"></i>No metadata found';
|
||
} else {
|
||
document.getElementById('exifNoData').classList.add('d-none');
|
||
|
||
// Categorize EXIF fields
|
||
const categories = {
|
||
'Camera': ['Make', 'Model', 'Software', 'LensMake', 'LensModel', 'BodySerialNumber'],
|
||
'Image': ['ImageWidth', 'ImageLength', 'Orientation', 'ResolutionUnit', 'XResolution', 'YResolution', 'ColorSpace', 'ExifImageWidth', 'ExifImageHeight'],
|
||
'Date/Time': ['DateTime', 'DateTimeOriginal', 'DateTimeDigitized', 'SubsecTime', 'SubsecTimeOriginal', 'SubsecTimeDigitized', 'OffsetTime', 'OffsetTimeOriginal'],
|
||
'Exposure': ['ExposureTime', 'FNumber', 'ExposureProgram', 'ISOSpeedRatings', 'ExposureBiasValue', 'MaxApertureValue', 'MeteringMode', 'Flash', 'FocalLength', 'FocalLengthIn35mmFilm', 'WhiteBalance', 'ExposureMode', 'DigitalZoomRatio', 'SceneCaptureType', 'Contrast', 'Saturation', 'Sharpness'],
|
||
'GPS': ['GPSInfo', 'GPSLatitude', 'GPSLatitudeRef', 'GPSLongitude', 'GPSLongitudeRef', 'GPSAltitude', 'GPSAltitudeRef', 'GPSTimeStamp', 'GPSDateStamp'],
|
||
};
|
||
|
||
const categorized = {};
|
||
const other = [];
|
||
const allCategoryFields = new Set(Object.values(categories).flat());
|
||
|
||
entries.forEach(([key, value]) => {
|
||
let found = false;
|
||
for (const [cat, fields] of Object.entries(categories)) {
|
||
if (fields.includes(key)) {
|
||
if (!categorized[cat]) categorized[cat] = [];
|
||
categorized[cat].push([key, value]);
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!found) other.push([key, value]);
|
||
});
|
||
|
||
// Render cards
|
||
let html = '';
|
||
const renderCard = ([key, value]) => {
|
||
let displayVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||
const needsTruncate = displayVal.length > 60;
|
||
if (needsTruncate) displayVal = displayVal.substring(0, 57) + '...';
|
||
const fullVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||
return `<div class="exif-card" title="${fullVal.replace(/"/g, '"')}">
|
||
<div class="exif-card-label">${key}</div>
|
||
<div class="exif-card-value${needsTruncate ? ' truncated' : ''}">${displayVal}</div>
|
||
</div>`;
|
||
};
|
||
|
||
// Render each category
|
||
for (const [cat, fields] of Object.entries(categories)) {
|
||
if (categorized[cat] && categorized[cat].length > 0) {
|
||
html += `<div class="exif-category"><i class="bi bi-${cat === 'Camera' ? 'camera' : cat === 'Image' ? 'image' : cat === 'Date/Time' ? 'clock' : cat === 'Exposure' ? 'aperture' : cat === 'GPS' ? 'geo-alt' : 'tag'} me-1"></i>${cat}</div>`;
|
||
html += categorized[cat].map(renderCard).join('');
|
||
}
|
||
}
|
||
|
||
// Render other fields
|
||
if (other.length > 0) {
|
||
html += `<div class="exif-category"><i class="bi bi-three-dots me-1"></i>Other</div>`;
|
||
html += other.map(renderCard).join('');
|
||
}
|
||
|
||
grid.innerHTML = html;
|
||
}
|
||
|
||
document.getElementById('exifEmpty').classList.add('d-none');
|
||
document.getElementById('exifData').classList.remove('d-none');
|
||
document.getElementById('exifActions').classList.remove('d-none');
|
||
} else {
|
||
// API returned success: false
|
||
console.error('EXIF API error:', data.error);
|
||
document.getElementById('exifNoData').classList.remove('d-none');
|
||
document.getElementById('exifNoData').innerHTML = `<i class="bi bi-exclamation-triangle d-block mb-2"></i>${data.error || 'Error reading metadata'}`;
|
||
document.getElementById('exifEmpty').classList.add('d-none');
|
||
document.getElementById('exifData').classList.remove('d-none');
|
||
}
|
||
} catch (err) {
|
||
console.error('EXIF fetch error:', err);
|
||
document.getElementById('exifNoData').classList.remove('d-none');
|
||
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-exclamation-triangle d-block mb-2"></i>Error loading metadata';
|
||
document.getElementById('exifEmpty').classList.add('d-none');
|
||
document.getElementById('exifData').classList.remove('d-none');
|
||
}
|
||
});
|
||
|
||
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');
|
||
|
||
// Show appropriate DCT warning based on file type
|
||
const isJpeg = file.type === 'image/jpeg' || file.name.toLowerCase().match(/\.jpe?g$/);
|
||
document.getElementById('rotateJpegSafe').style.display = isJpeg ? 'block' : 'none';
|
||
document.getElementById('rotateNonJpegWarn').style.display = isJpeg ? 'none' : 'block';
|
||
|
||
// 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');
|
||
document.getElementById('rotateJpegSafe').style.display = 'none';
|
||
document.getElementById('rotateNonJpegWarn').style.display = '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;
|
||
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || 'rotated.jpg';
|
||
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 %}
|