Files
2eInk/src/routes/+page.svelte
patrick 5e230a7bd2 Move Clear All button inline with Download All
- Relocate to action buttons row
- Add red outline styling with solid fill on hover

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-14 09:11:09 -04:00

603 lines
21 KiB
Svelte

<script lang="ts">
import {
Download,
Trash2,
Image,
Eye,
ImageIcon,
SlidersHorizontal,
ArrowDown,
Monitor,
Sliders,
ShieldCheck,
Layers,
SplitSquareHorizontal,
GitBranch
} from 'lucide-svelte';
import {
DropZone,
DeviceSelector,
CustomDeviceModal,
EditImageModal,
ImageGrid,
EreaderMockup,
DitherComparison,
deviceStore,
imagesStore,
pipelineStore,
downloadAllAsZip,
type PipelineConfig,
type ImageEntry
} from '$lib';
import type { ViewMode } from '$lib/components/ImageCard.svelte';
let showCustomModal = $state(false);
let showEditModal = $state(false);
let editingImageId = $state<string | null>(null);
// Derive the editing image from the store to always get the latest data
const editingImage = $derived(
editingImageId ? imagesStore.images.find((img) => img.id === editingImageId) ?? null : null
);
// View mode for image comparison
let viewMode = $state<ViewMode>('compare');
// Track previous device to detect changes
let previousDeviceId = $state(deviceStore.selected.id);
let previousPresetId = $state(pipelineStore.selectedPresetId);
// Watch for device changes and reprocess
$effect(() => {
const currentDeviceId = deviceStore.selected.id;
if (currentDeviceId !== previousDeviceId && imagesStore.hasImages) {
previousDeviceId = currentDeviceId;
imagesStore.reprocessAll(deviceStore.selected);
}
});
// Watch for pipeline preset changes and reprocess images without custom overrides
$effect(() => {
const currentPresetId = pipelineStore.selectedPresetId;
if (currentPresetId !== previousPresetId && imagesStore.hasImages) {
previousPresetId = currentPresetId;
// Only reprocess images that don't have custom overrides
for (const img of imagesStore.images) {
if (!img.pipelineOverride) {
imagesStore.reprocessImage(img.id, deviceStore.selected);
}
}
}
});
function handlePresetChange(e: Event): void {
const select = e.target as HTMLSelectElement;
pipelineStore.selectPreset(select.value);
}
function handleFiles(files: FileList): void {
imagesStore.addImages(files, deviceStore.selected);
}
function handleRemoveImage(id: string): void {
imagesStore.removeImage(id);
}
function handleEditImage(id: string): void {
editingImageId = id;
showEditModal = true;
}
function closeEditModal(): void {
showEditModal = false;
editingImageId = null;
}
function handleApplyPipeline(imageId: string, config: PipelineConfig, processedDataUrl: string | null, processedBlob: Blob | null): void {
imagesStore.setPipelineOverride(imageId, config);
if (processedDataUrl && processedBlob) {
// Use the already-processed preview directly
imagesStore.updateImage(imageId, { processedDataUrl, processedBlob });
} else {
// Fallback: reprocess if no preview available
imagesStore.reprocessImage(imageId, deviceStore.selected);
}
}
function handleClearAll(): void {
imagesStore.clearAll();
}
async function handleDownloadAll(): Promise<void> {
await downloadAllAsZip(imagesStore.images, deviceStore.selected);
}
function openCustomModal(): void {
showCustomModal = true;
}
function closeCustomModal(): void {
showCustomModal = false;
}
</script>
<svelte:head>
<title>2eInk - E-Reader Screensaver Converter</title>
<meta name="description" content="Convert images to optimized screensavers for Kindle, Kobo, and other e-ink devices" />
</svelte:head>
<main class="min-h-screen bg-paper">
<!-- Navigation -->
<nav class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-grey-100">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
<a href="/" class="flex items-center gap-2">
<EreaderMockup class="w-6 h-auto" />
<span class="text-lg font-semibold text-ink">2eInk</span>
<span class="text-sm text-grey-400 hidden sm:inline">· Image Converter</span>
</a>
<div class="flex items-center gap-6">
<div class="hidden sm:flex items-center gap-6">
<a href="#features" class="text-sm text-grey-500 hover:text-ink transition-colors">
Features
</a>
<a href="#about" class="text-sm text-grey-500 hover:text-ink transition-colors">
About
</a>
</div>
<a
href="#converter"
class="
inline-flex items-center gap-1.5
px-4 py-1.5
bg-accent text-white
rounded-md
text-sm font-medium
hover:bg-accent-hover
transition-colors duration-150
"
>
Start Converting
</a>
</div>
</div>
</div>
</nav>
<!-- Hero Section -->
<section class="relative overflow-hidden border-b border-grey-100">
<!-- Faint pattern background -->
<div class="absolute inset-0 bg-gradient-to-br from-grey-50 via-paper to-grey-50"></div>
<!-- Blue accent gradient from top-left corner -->
<div class="absolute inset-0 bg-gradient-to-br from-accent/[0.12] via-accent/[0.04] to-transparent"></div>
<div class="absolute inset-0 opacity-[0.03]" style="background-image: url('data:image/svg+xml,%3Csvg width=%2260%22 height=%2260%22 viewBox=%220 0 60 60%22 xmlns=%22http://www.w3.org/2000/svg%22%3E%3Cg fill=%22none%22 fill-rule=%22evenodd%22%3E%3Cg fill=%22%23000%22%3E%3Cpath d=%22M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z%22/%3E%3C/g%3E%3C/g%3E%3C/svg%3E');"></div>
<div class="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20">
<div class="flex flex-col lg:flex-row items-center gap-8 lg:gap-12">
<!-- Text content -->
<div class="flex-1 text-center lg:text-left">
<h1 class="text-3xl sm:text-4xl font-bold text-ink tracking-tight">
Perfect Screensavers for Your E-Reader
</h1>
<p class="mt-4 text-lg text-grey-500 max-w-xl mx-auto lg:mx-0">
Convert any image into beautifully optimized covers for Kindle, Kobo, and other e-ink devices.
Automatic greyscale conversion, dithering, and perfect sizing.
</p>
<div class="mt-6 flex flex-col sm:flex-row gap-3 justify-center lg:justify-start">
<a
href="#converter"
class="
inline-flex items-center justify-center gap-2
px-6 py-3
bg-accent text-white
rounded-lg
text-base font-semibold
hover:bg-accent-hover
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
transition-colors duration-150
shadow-sm
"
>
Start Converting
<ArrowDown class="w-4 h-4" />
</a>
</div>
</div>
<!-- E-reader mockup -->
<div class="flex-shrink-0">
<EreaderMockup class="w-48 sm:w-56 lg:w-64 h-auto drop-shadow-xl translate-x-2 animate-float" />
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" class="py-16 bg-paper border-b border-grey-100 scroll-mt-16">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-2xl font-bold text-ink">How It Works</h2>
<p class="mt-2 text-grey-500">Everything you need to create perfect e-reader screensavers</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<!-- Feature 1: E-ink Optimized -->
<div class="flex flex-col items-center text-center p-6">
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center mb-4">
<ImageIcon class="w-6 h-6 text-accent" />
</div>
<h3 class="text-lg font-semibold text-ink mb-2">E-ink Optimized</h3>
<p class="text-grey-500 text-sm">
Greyscale conversion, Floyd-Steinberg dithering, and quantization tuned specifically for e-ink displays.
</p>
</div>
<!-- Feature 2: Device Presets -->
<div class="flex flex-col items-center text-center p-6">
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center mb-4">
<Monitor class="w-6 h-6 text-accent" />
</div>
<h3 class="text-lg font-semibold text-ink mb-2">Device Presets</h3>
<p class="text-grey-500 text-sm">
Pre-configured settings for Kindle, Kobo, reMarkable, and more. Create custom presets for any device.
</p>
</div>
<!-- Feature 3: Fine-Tuned Control -->
<div class="flex flex-col items-center text-center p-6">
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center mb-4">
<Sliders class="w-6 h-6 text-accent" />
</div>
<h3 class="text-lg font-semibold text-ink mb-2">Fine-Tuned Control</h3>
<p class="text-grey-500 text-sm">
Adjust brightness, contrast, gamma, sharpening, and crop settings per image for perfect results.
</p>
</div>
<!-- Feature 4: 100% Private -->
<div class="flex flex-col items-center text-center p-6">
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center mb-4">
<ShieldCheck class="w-6 h-6 text-accent" />
</div>
<h3 class="text-lg font-semibold text-ink mb-2">100% Private</h3>
<p class="text-grey-500 text-sm">
Runs entirely in your browser using WebAssembly. Your images never leave your device.
</p>
</div>
<!-- Feature 5: Batch Processing -->
<div class="flex flex-col items-center text-center p-6">
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center mb-4">
<Layers class="w-6 h-6 text-accent" />
</div>
<h3 class="text-lg font-semibold text-ink mb-2">Batch Processing</h3>
<p class="text-grey-500 text-sm">
Drop multiple images at once and download them all as a convenient ZIP archive.
</p>
</div>
<!-- Feature 6: Real-time Preview -->
<div class="flex flex-col items-center text-center p-6">
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center mb-4">
<SplitSquareHorizontal class="w-6 h-6 text-accent" />
</div>
<h3 class="text-lg font-semibold text-ink mb-2">Real-time Preview</h3>
<p class="text-grey-500 text-sm">
Compare original and processed images side-by-side with an interactive slider.
</p>
</div>
</div>
</div>
</section>
<!-- About Section -->
<section id="about" class="py-16 bg-white border-b border-grey-100 scroll-mt-16">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-2xl font-bold text-ink">Optimized for E-ink</h2>
<p class="mt-2 text-grey-500 max-w-2xl mx-auto">
E-ink displays work differently than regular screens. Here's how we optimize your images for the best results.
</p>
</div>
<div class="space-y-12">
<!-- Dithering explanation -->
<div class="flex flex-col lg:flex-row items-center gap-8">
<div class="flex-1 order-2 lg:order-1">
<h3 class="text-xl font-semibold text-ink mb-3">Dithering Simulates More Shades</h3>
<p class="text-grey-500 mb-4">
E-ink displays typically only show 16 grey levels. Without dithering, gradients appear as harsh bands.
Dithering uses patterns of dots to simulate smooth transitions, making images look natural on limited displays.
</p>
<p class="text-sm text-grey-400">
We support Floyd-Steinberg (smooth, natural) and Atkinson (higher contrast, retro) algorithms.
</p>
</div>
<div class="flex-shrink-0 order-1 lg:order-2">
<DitherComparison />
</div>
</div>
<!-- Grey levels explanation -->
<div class="flex flex-col lg:flex-row items-center gap-8">
<div class="flex-shrink-0">
<!-- Grey levels comparison -->
<div class="flex gap-3">
{#each [2, 4, 16] as levels}
<div class="text-center">
<svg width="80" height="120" viewBox="0 0 80 120" class="rounded-lg border border-grey-200 shadow-sm">
{#each Array(levels) as _, i}
<rect
x="0"
y={i * (120 / levels)}
width="80"
height={120 / levels}
fill={`rgb(${255 - (i * 255 / (levels - 1))}, ${255 - (i * 255 / (levels - 1))}, ${255 - (i * 255 / (levels - 1))})`}
/>
{/each}
</svg>
<p class="text-xs text-grey-400 mt-2">{levels} levels</p>
</div>
{/each}
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold text-ink mb-3">Grey Levels Match Your Device</h3>
<p class="text-grey-500 mb-4">
Different e-readers support different numbers of grey levels. Basic Kindles show 16 levels,
while premium displays may show 256. We quantize your images to match your device's capabilities.
</p>
<p class="text-sm text-grey-400">
More levels = smoother gradients, but dithering can make even 16 levels look great.
</p>
</div>
</div>
<!-- Sizing explanation -->
<div class="flex flex-col lg:flex-row items-center gap-8">
<div class="flex-1 order-2 lg:order-1">
<h3 class="text-xl font-semibold text-ink mb-3">Perfect Sizing for Sharp Results</h3>
<p class="text-grey-500 mb-4">
E-ink displays render at their native resolution. Images that don't match get scaled by the device,
often poorly. We resize your images to exactly match your e-reader's screen dimensions.
</p>
<p class="text-sm text-grey-400">
Choose from cover (fill screen, crop edges) or contain (fit entirely, may letterbox) modes.
</p>
</div>
<div class="flex-shrink-0 order-1 lg:order-2">
<div class="flex gap-4 items-center">
<!-- Mismatched size -->
<div class="text-center">
<div class="w-16 h-24 rounded border-2 border-dashed border-grey-300 flex items-center justify-center bg-grey-50">
<div class="w-12 h-12 bg-grey-400 rounded-sm"></div>
</div>
<p class="text-xs text-grey-400 mt-2">Wrong size</p>
</div>
<!-- Arrow -->
<div class="text-grey-300 -mt-6">
<ArrowDown class="w-5 h-5 rotate-[-90deg]" />
</div>
<!-- Perfect size -->
<div class="text-center">
<div class="w-16 h-24 rounded border-2 border-accent bg-grey-400"></div>
<p class="text-xs text-accent mt-2">Perfect fit</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<div id="converter" class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 scroll-mt-16">
<!-- Section header -->
<div class="text-center mb-10">
<h2 class="text-2xl font-bold text-ink">Convert Your Images</h2>
<p class="mt-2 text-grey-500">Drop your images below and download optimized screensavers</p>
</div>
<!-- Converter card -->
<div class="bg-white border border-grey-200 rounded-xl shadow-sm p-6 sm:p-10">
<!-- Controls bar -->
<div class="flex flex-col sm:flex-row sm:items-end justify-between gap-4 mb-6">
<div class="flex-1 max-w-xs">
<span class="block text-xs font-medium text-grey-500 uppercase tracking-wide mb-1.5">
Device
</span>
<DeviceSelector onCustomClick={openCustomModal} />
</div>
<div class="w-full sm:w-auto sm:max-w-xs">
<label for="pipeline-select" class="block text-xs font-medium text-grey-500 uppercase tracking-wide mb-1.5">
Pipeline
</label>
<div class="flex items-center gap-2">
<select
id="pipeline-select"
class="
flex-1 px-3 py-2
bg-white text-ink text-sm
border border-grey-200 rounded-md
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
cursor-pointer
"
value={pipelineStore.selectedPresetId ?? 'default'}
onchange={handlePresetChange}
>
{#each pipelineStore.allPresets as preset}
<option value={preset.id}>
{preset.name}{preset.isBuiltIn ? '' : ' (custom)'}
</option>
{/each}
</select>
<SlidersHorizontal class="w-4 h-4 text-grey-400" />
</div>
</div>
</div>
<!-- Drop zone -->
<div class="mb-8">
<DropZone onFiles={handleFiles} />
</div>
<!-- Images section -->
{#if imagesStore.hasImages}
<section>
<!-- Images header with controls -->
<div class="flex flex-col gap-3 mb-4">
<div class="flex items-center justify-between">
<h2 class="text-sm font-medium text-grey-500 uppercase tracking-wide">
Images ({imagesStore.images.length})
</h2>
</div>
<!-- View mode toggle and action buttons -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-grey-500 uppercase tracking-wide">View:</span>
<div class="flex rounded-md border border-grey-200 overflow-hidden">
<button
type="button"
class="
flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium
transition-colors duration-150
{viewMode === 'original' ? 'bg-accent text-white' : 'bg-white text-grey-600 hover:bg-grey-50'}
"
onclick={() => (viewMode = 'original')}
>
<ImageIcon class="w-4 h-4" />
Original
</button>
<button
type="button"
class="
flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium border-x border-grey-200
transition-colors duration-150
{viewMode === 'compare' ? 'bg-accent text-white' : 'bg-white text-grey-600 hover:bg-grey-50'}
"
onclick={() => (viewMode = 'compare')}
>
<SlidersHorizontal class="w-4 h-4" />
Compare
</button>
<button
type="button"
class="
flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium
transition-colors duration-150
{viewMode === 'processed' ? 'bg-accent text-white' : 'bg-white text-grey-600 hover:bg-grey-50'}
"
onclick={() => (viewMode = 'processed')}
>
<Eye class="w-4 h-4" />
Processed
</button>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="
flex items-center gap-1.5
px-4 py-2
text-error
border border-error
rounded-md
text-sm font-medium
hover:bg-error hover:text-white
transition-colors duration-150
"
onclick={handleClearAll}
>
<Trash2 class="w-4 h-4" />
Clear All
</button>
{#if imagesStore.completeCount > 0}
<button
type="button"
class="
flex items-center justify-center gap-2
px-4 py-2
bg-accent text-white
rounded-md
text-sm font-medium
hover:bg-accent-hover
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
transition-colors duration-150
"
onclick={handleDownloadAll}
>
<Download class="w-4 h-4" />
Download All ({imagesStore.completeCount})
</button>
{/if}
</div>
</div>
</div>
<ImageGrid
images={imagesStore.images}
device={deviceStore.selected}
onRemove={handleRemoveImage}
onEdit={handleEditImage}
{viewMode}
/>
</section>
{:else}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-16 text-center">
<Image class="w-16 h-16 text-grey-300 mb-4" />
<p class="text-grey-400 text-sm">
No images yet. Drop some images above to get started.
</p>
</div>
{/if}
</div>
</div>
<!-- Footer -->
<footer class="border-t border-grey-100 bg-grey-50">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-2 text-grey-500 text-sm">
<EreaderMockup class="w-4 h-auto" />
<span class="font-medium text-ink">2eInk</span>
<span class="text-grey-300">·</span>
<span>Free & open source</span>
</div>
<div class="flex items-center gap-6 text-sm text-grey-500">
<a href="#features" class="hover:text-accent transition-colors">Features</a>
<a href="#about" class="hover:text-accent transition-colors">About</a>
<a href="#converter" class="hover:text-accent transition-colors">Converter</a>
<a
href="https://git.jaroszew.ski/patrick/2eInk"
target="_blank"
rel="noopener noreferrer"
class="hover:text-accent transition-colors"
title="Source code"
>
<GitBranch class="w-4 h-4" />
</a>
</div>
</div>
<div class="mt-6 pt-6 border-t border-grey-200 text-center text-xs text-grey-400">
All processing happens locally in your browser. No data is ever uploaded.
</div>
</div>
</footer>
</main>
<!-- Custom device modal -->
<CustomDeviceModal open={showCustomModal} onClose={closeCustomModal} />
<!-- Edit image modal -->
<EditImageModal
open={showEditModal}
image={editingImage}
device={deviceStore.selected}
onClose={closeEditModal}
onApply={handleApplyPipeline}
/>