Add modern UI with dark mode, live preview, and sleek design
This commit is contained in:
@@ -1,27 +1,67 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { transformImage } from "./lib/api";
|
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 file: File | null = null;
|
||||||
|
let filePreviewUrl: string | null = null;
|
||||||
let width: number | null = null;
|
let width: number | null = null;
|
||||||
let height: number | null = null;
|
let height: number | null = null;
|
||||||
let quality = 80;
|
let quality = 80;
|
||||||
let format: "png" | "webp" | "jpeg" = "png";
|
let format: "png" | "webp" | "jpeg" = "png";
|
||||||
|
let fit: "inside" | "cover" = "inside";
|
||||||
// cropping / resizing
|
let position = "center";
|
||||||
let fit: "inside" | "cover" = "inside"; // inside = resize only, cover = crop
|
|
||||||
let position:
|
|
||||||
| "center"
|
|
||||||
| "top"
|
|
||||||
| "right"
|
|
||||||
| "bottom"
|
|
||||||
| "left"
|
|
||||||
| "top-left"
|
|
||||||
| "top-right"
|
|
||||||
| "bottom-left"
|
|
||||||
| "bottom-right" = "center";
|
|
||||||
|
|
||||||
let processing = false;
|
let processing = false;
|
||||||
let error: string | null = null;
|
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() {
|
async function onSubmit() {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -37,7 +77,7 @@
|
|||||||
quality,
|
quality,
|
||||||
format,
|
format,
|
||||||
fit,
|
fit,
|
||||||
position
|
position: fit === "cover" ? position : undefined
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -56,103 +96,236 @@
|
|||||||
function onFileChange(e: Event) {
|
function onFileChange(e: Event) {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
file = target.files?.[0] || null;
|
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>
|
</script>
|
||||||
|
|
||||||
<main>
|
<div class="container">
|
||||||
<h1>PNG Editor</h1>
|
<!-- Header -->
|
||||||
|
<header class="flex justify-between items-center" style="margin-bottom: var(--space-2xl);">
|
||||||
<input type="file" accept="image/*" on:change={onFileChange} />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label>Width: <input type="number" bind:value={width} min="1" /></label>
|
|
||||||
<label>Height: <input type="number" bind:value={height} min="1" /></label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label>Fit mode:
|
|
||||||
<select bind:value={fit}>
|
|
||||||
<option value="inside">Resize only (no crop)</option>
|
|
||||||
<option value="cover">Crop to fit box</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if fit === "cover"}
|
|
||||||
<div>
|
<div>
|
||||||
<label>Crop position:
|
<h1 class="mb-0">PNGer</h1>
|
||||||
<select bind:value={position}>
|
<p class="text-sm mb-0">Modern PNG Editor & Resizer</p>
|
||||||
<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>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<button class="btn-outline" on:click={() => theme.toggle()}>
|
||||||
|
{#if $theme === 'dark'}
|
||||||
|
☀️ Light
|
||||||
|
{:else}
|
||||||
|
🌙 Dark
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div>
|
<div class="grid grid-cols-2 gap-lg">
|
||||||
<label>Quality:
|
<!-- Left Column: Upload & Controls -->
|
||||||
<input type="range" min="10" max="100" bind:value={quality} />
|
<div class="card fade-in">
|
||||||
</label>
|
<h2>Upload & Transform</h2>
|
||||||
<span>{quality}</span>
|
|
||||||
|
<!-- File Upload -->
|
||||||
|
<div style="margin-bottom: var(--space-xl);">
|
||||||
|
<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}
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Right Column: Preview -->
|
||||||
|
<div class="card fade-in" style="display: flex; flex-direction: column;">
|
||||||
|
<h2>Live Preview</h2>
|
||||||
|
|
||||||
|
{#if !file}
|
||||||
|
<div style="flex: 1; display: flex; align-items: center; justify-content: center; color: var(--color-text-secondary); text-align: center; padding: var(--space-2xl);">
|
||||||
|
<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="flex: 1; display: flex; flex-direction: column; gap: var(--space-lg);">
|
||||||
|
<!-- Image Comparison -->
|
||||||
|
<div class="grid grid-cols-2 gap-md" style="flex: 1;">
|
||||||
|
<!-- Original -->
|
||||||
|
<div style="display: flex; flex-direction: column;">
|
||||||
|
<h3 style="font-size: 1rem; margin-bottom: var(--space-sm);">Original</h3>
|
||||||
|
<div style="flex: 1; 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);">
|
||||||
|
<img
|
||||||
|
src={filePreviewUrl}
|
||||||
|
alt="Original"
|
||||||
|
style="max-width: 100%; max-height: 300px; 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="flex: 1; 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);">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Preview"
|
||||||
|
style="max-width: 100%; max-height: 300px; 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="flex: 1; display: flex; align-items: center; justify-content: center; color: var(--color-text-secondary);">
|
||||||
|
<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>
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
|
||||||
<label>Format:
|
|
||||||
<select bind:value={format}>
|
|
||||||
<option value="png">PNG</option>
|
|
||||||
<option value="webp">WebP</option>
|
|
||||||
<option value="jpeg">JPEG</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<p style="color: red">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button on:click|preventDefault={onSubmit} disabled={processing}>
|
|
||||||
{processing ? "Processing..." : "Transform & Download"}
|
|
||||||
</button>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
main {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 2rem auto;
|
|
||||||
padding: 1rem;
|
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="number"],
|
|
||||||
select {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Reference in New Issue
Block a user