339 lines
11 KiB
Svelte
339 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { transformImage } from "./lib/api";
|
|
import {
|
|
generateClientPreview,
|
|
estimateSize,
|
|
formatFileSize,
|
|
calculateSavings,
|
|
debounce,
|
|
type TransformOptions
|
|
} from "./lib/preview";
|
|
import { theme } from "./lib/theme";
|
|
|
|
let file: File | null = null;
|
|
let filePreviewUrl: string | null = null;
|
|
let width: number | null = null;
|
|
let height: number | null = null;
|
|
let quality = 80;
|
|
let format: "png" | "webp" | "jpeg" = "png";
|
|
let fit: "inside" | "cover" = "inside";
|
|
let position = "center";
|
|
|
|
let processing = false;
|
|
let error: string | null = null;
|
|
|
|
// Preview state
|
|
let previewUrl: string | null = null;
|
|
let previewSize = 0;
|
|
let originalSize = 0;
|
|
let showPreview = false;
|
|
|
|
// Generate preview with debounce
|
|
const updatePreview = debounce(async () => {
|
|
if (!file) {
|
|
previewUrl = null;
|
|
showPreview = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const options: TransformOptions = {
|
|
width: width || undefined,
|
|
height: height || undefined,
|
|
quality,
|
|
format,
|
|
fit,
|
|
position: fit === "cover" ? position : undefined
|
|
};
|
|
|
|
previewUrl = await generateClientPreview(file, options);
|
|
previewSize = estimateSize(previewUrl);
|
|
originalSize = file.size;
|
|
showPreview = true;
|
|
} catch (err) {
|
|
console.error("Preview generation failed:", err);
|
|
}
|
|
}, 300);
|
|
|
|
// Reactive preview updates
|
|
$: if (file) {
|
|
updatePreview();
|
|
}
|
|
$: if (width !== null || height !== null || quality || format || fit || position) {
|
|
if (file) updatePreview();
|
|
}
|
|
|
|
async function onSubmit() {
|
|
if (!file) {
|
|
error = "Please select an image file";
|
|
return;
|
|
}
|
|
error = null;
|
|
processing = true;
|
|
try {
|
|
const blob = await transformImage(file, {
|
|
width: width || undefined,
|
|
height: height || undefined,
|
|
quality,
|
|
format,
|
|
fit,
|
|
position: fit === "cover" ? position : undefined
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `output.${format}`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
error = "Processing failed";
|
|
console.error(e);
|
|
} finally {
|
|
processing = false;
|
|
}
|
|
}
|
|
|
|
function onFileChange(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
file = target.files?.[0] || null;
|
|
if (file) {
|
|
filePreviewUrl = URL.createObjectURL(file);
|
|
} else {
|
|
filePreviewUrl = null;
|
|
}
|
|
}
|
|
|
|
function clearFile() {
|
|
file = null;
|
|
filePreviewUrl = null;
|
|
previewUrl = null;
|
|
showPreview = false;
|
|
originalSize = 0;
|
|
previewSize = 0;
|
|
}
|
|
|
|
const savings = showPreview ? calculateSavings(originalSize, previewSize) : null;
|
|
</script>
|
|
|
|
<div class="container">
|
|
<!-- Header -->
|
|
<header class="flex justify-between items-center" style="margin-bottom: var(--space-2xl);">
|
|
<div>
|
|
<h1 class="mb-0">PNGer</h1>
|
|
<p class="text-sm mb-0">Modern PNG Editor & Resizer</p>
|
|
</div>
|
|
<button class="btn-outline" on:click={() => theme.toggle()}>
|
|
{#if $theme === 'dark'}
|
|
☀️ Light
|
|
{:else}
|
|
🌙 Dark
|
|
{/if}
|
|
</button>
|
|
</header>
|
|
|
|
<!-- Controls Section -->
|
|
<div class="card fade-in" style="margin-bottom: var(--space-xl);">
|
|
<div class="grid grid-cols-2 gap-lg">
|
|
<!-- Left Column: Upload & Dimensions -->
|
|
<div>
|
|
<h2>Upload & Settings</h2>
|
|
|
|
<!-- File Upload -->
|
|
<div style="margin-bottom: var(--space-lg);">
|
|
<label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">
|
|
Select Image
|
|
</label>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
on:change={onFileChange}
|
|
style="margin-bottom: var(--space-sm);"
|
|
/>
|
|
{#if file}
|
|
<div class="flex gap-sm items-center" style="margin-top: var(--space-sm);">
|
|
<span class="text-sm">{file.name}</span>
|
|
<span class="text-xs" style="color: var(--color-text-secondary);">
|
|
({formatFileSize(file.size)})
|
|
</span>
|
|
<button class="btn-secondary" style="padding: var(--space-xs) var(--space-sm); font-size: 0.875rem;" on:click={clearFile}>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Dimensions -->
|
|
<div style="margin-bottom: var(--space-lg);">
|
|
<h3>Dimensions</h3>
|
|
<div class="grid grid-cols-2 gap-md">
|
|
<div>
|
|
<label style="display: block; margin-bottom: var(--space-xs); font-size: 0.875rem;">
|
|
Width (px)
|
|
</label>
|
|
<input type="number" bind:value={width} min="1" placeholder="Auto" />
|
|
</div>
|
|
<div>
|
|
<label style="display: block; margin-bottom: var(--space-xs); font-size: 0.875rem;">
|
|
Height (px)
|
|
</label>
|
|
<input type="number" bind:value={height} min="1" placeholder="Auto" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fit Mode -->
|
|
<div style="margin-bottom: var(--space-lg);">
|
|
<label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">
|
|
Fit Mode
|
|
</label>
|
|
<select bind:value={fit}>
|
|
<option value="inside">Resize only (no crop)</option>
|
|
<option value="cover">Crop to fit box</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Crop Position (if cover) -->
|
|
{#if fit === "cover"}
|
|
<div style="margin-bottom: var(--space-lg);" class="fade-in">
|
|
<label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">
|
|
Crop Position
|
|
</label>
|
|
<select bind:value={position}>
|
|
<option value="center">Center</option>
|
|
<option value="top">Top</option>
|
|
<option value="bottom">Bottom</option>
|
|
<option value="left">Left</option>
|
|
<option value="right">Right</option>
|
|
<option value="top-left">Top-left</option>
|
|
<option value="top-right">Top-right</option>
|
|
<option value="bottom-left">Bottom-left</option>
|
|
<option value="bottom-right">Bottom-right</option>
|
|
</select>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Right Column: Quality & Format -->
|
|
<div>
|
|
<h2>Quality & Format</h2>
|
|
|
|
<!-- Quality -->
|
|
<div style="margin-bottom: var(--space-lg);">
|
|
<div class="flex justify-between" style="margin-bottom: var(--space-sm);">
|
|
<label style="font-weight: 500;">Quality</label>
|
|
<span style="color: var(--color-accent); font-weight: 600;">{quality}%</span>
|
|
</div>
|
|
<input type="range" min="10" max="100" bind:value={quality} />
|
|
</div>
|
|
|
|
<!-- Format -->
|
|
<div style="margin-bottom: var(--space-xl);">
|
|
<label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">
|
|
Output Format
|
|
</label>
|
|
<select bind:value={format}>
|
|
<option value="png">PNG</option>
|
|
<option value="webp">WebP</option>
|
|
<option value="jpeg">JPEG</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Error Message -->
|
|
{#if error}
|
|
<p style="color: var(--color-error); padding: var(--space-md); background: var(--color-bg-tertiary); border-radius: var(--radius-md); margin-bottom: var(--space-lg);">
|
|
{error}
|
|
</p>
|
|
{/if}
|
|
|
|
<!-- Action Button -->
|
|
<button
|
|
on:click|preventDefault={onSubmit}
|
|
disabled={processing || !file}
|
|
style="width: 100%;"
|
|
>
|
|
{#if processing}
|
|
<span class="spinner" style="width: 16px; height: 16px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%;"></span>
|
|
Processing...
|
|
{:else}
|
|
⬇️ Transform & Download
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Live Preview Section (Full Width Below) -->
|
|
<div class="card fade-in">
|
|
<h2>Live Preview</h2>
|
|
|
|
{#if !file}
|
|
<div style="display: flex; align-items: center; justify-content: center; color: var(--color-text-secondary); text-align: center; padding: var(--space-2xl); min-height: 400px;">
|
|
<div>
|
|
<p style="font-size: 3rem; margin-bottom: var(--space-md)">🖼️</p>
|
|
<p class="mb-0">Upload an image to see live preview</p>
|
|
</div>
|
|
</div>
|
|
{:else if showPreview}
|
|
<div style="display: flex; flex-direction: column; gap: var(--space-lg);">
|
|
<!-- Image Comparison -->
|
|
<div class="grid grid-cols-2 gap-lg">
|
|
<!-- Original -->
|
|
<div style="display: flex; flex-direction: column;">
|
|
<h3 style="font-size: 1rem; margin-bottom: var(--space-sm);">Original</h3>
|
|
<div style="border: 2px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; display: flex; align-items: center; justify-content: center; background: var(--color-bg-tertiary); min-height: 500px;">
|
|
<img
|
|
src={filePreviewUrl}
|
|
alt="Original"
|
|
style="max-width: 100%; max-height: 600px; object-fit: contain;"
|
|
/>
|
|
</div>
|
|
<div style="margin-top: var(--space-sm); text-align: center;">
|
|
<p class="text-sm mb-0">
|
|
{formatFileSize(originalSize)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Preview -->
|
|
<div style="display: flex; flex-direction: column;">
|
|
<h3 style="font-size: 1rem; margin-bottom: var(--space-sm);">Preview</h3>
|
|
<div style="border: 2px solid var(--color-accent); border-radius: var(--radius-md); overflow: hidden; display: flex; align-items: center; justify-content: center; background: var(--color-bg-tertiary); min-height: 500px;">
|
|
<img
|
|
src={previewUrl}
|
|
alt="Preview"
|
|
style="max-width: 100%; max-height: 600px; object-fit: contain;"
|
|
/>
|
|
</div>
|
|
<div style="margin-top: var(--space-sm); text-align: center;">
|
|
<p class="text-sm mb-0">
|
|
{formatFileSize(previewSize)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Savings Info -->
|
|
{#if savings}
|
|
<div
|
|
class="fade-in"
|
|
style="
|
|
padding: var(--space-lg);
|
|
background: {savings.isReduction ? 'var(--color-success)' : 'var(--color-warning)'}15;
|
|
border: 2px solid {savings.isReduction ? 'var(--color-success)' : 'var(--color-warning)'};
|
|
border-radius: var(--radius-md);
|
|
text-align: center;
|
|
"
|
|
>
|
|
<p class="text-sm font-semibold mb-0" style="color: {savings.isReduction ? 'var(--color-success)' : 'var(--color-warning)'}; font-size: 1.125rem;">
|
|
{savings.formatted}
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div style="display: flex; align-items: center; justify-content: center; color: var(--color-text-secondary); min-height: 500px;">
|
|
<div class="spinner" style="width: 40px; height: 40px; border: 3px solid var(--color-border); border-top-color: var(--color-accent); border-radius: 50%;"></div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div> |