Add modern UI with dark mode, live preview, and sleek design

This commit is contained in:
2026-03-08 16:23:11 -05:00
parent cdac0b0cd9
commit ef0edc0756

View File

@@ -1,28 +1,68 @@
<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";
// cropping / resizing
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 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";
@@ -37,7 +77,7 @@
quality,
format,
fit,
position
position: fit === "cover" ? position : undefined
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -56,31 +96,106 @@
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>
<main>
<h1>PNG Editor</h1>
<input type="file" accept="image/*" on:change={onFileChange} />
<div class="container">
<!-- Header -->
<header class="flex justify-between items-center" style="margin-bottom: var(--space-2xl);">
<div>
<label>Width: <input type="number" bind:value={width} min="1" /></label>
<label>Height: <input type="number" bind:value={height} min="1" /></label>
<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>
<div class="grid grid-cols-2 gap-lg">
<!-- Left Column: Upload & Controls -->
<div class="card fade-in">
<h2>Upload & Transform</h2>
<!-- 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>Fit mode:
<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>
</label>
</div>
<!-- Crop Position (if cover) -->
{#if fit === "cover"}
<div>
<label>Crop position:
<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>
@@ -92,67 +207,125 @@
<option value="bottom-left">Bottom-left</option>
<option value="bottom-right">Bottom-right</option>
</select>
</label>
</div>
{/if}
<div>
<label>Quality:
<!-- 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} />
</label>
<span>{quality}</span>
</div>
<div>
<label>Format:
<!-- 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>
</label>
</div>
<!-- Error Message -->
{#if error}
<p style="color: red">{error}</p>
<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}
<button on:click|preventDefault={onSubmit} disabled={processing}>
{processing ? "Processing..." : "Transform & Download"}
<!-- 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>
</main>
</div>
<style>
main {
max-width: 600px;
margin: 2rem auto;
padding: 1rem;
font-family: system-ui, -apple-system, sans-serif;
}
<!-- Right Column: Preview -->
<div class="card fade-in" style="display: flex; flex-direction: column;">
<h2>Live Preview</h2>
h1 {
margin-bottom: 2rem;
}
{#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>
label {
display: block;
margin: 1rem 0;
}
<!-- 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>
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>
<!-- 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>