Add modern UI with dark mode, live preview, and sleek design
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user