feat: add landing page and complete app

- EreaderMockup: animated SVG e-reader illustration
- DitherComparison: interactive before/after demo
- Landing page with hero, features, about sections
- Converter with view mode toggle and batch download
- Updated exports and theme styles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 18:52:48 -04:00
parent 963c1eb709
commit 4a6a63b2b0
8 changed files with 964 additions and 8 deletions

View File

@@ -0,0 +1,234 @@
<script lang="ts">
import { GripVertical } from 'lucide-svelte';
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
let containerRef: HTMLDivElement;
let canvasNoDither: HTMLCanvasElement;
let canvasDithered: HTMLCanvasElement;
let sliderPosition = $state(50);
let isDragging = $state(false);
let isProcessing = $state(true);
let isLoaded = $state(false);
const GREY_LEVELS = 16;
// Process images when component mounts
$effect(() => {
if (canvasNoDither && canvasDithered && !isLoaded) {
loadAndProcess();
}
});
async function loadAndProcess(): Promise<void> {
isProcessing = true;
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = reject;
img.src = '/cover.jpg';
});
// Crop to landscape from center of image
const outputWidth = 320;
const outputHeight = 200;
const aspectRatio = outputWidth / outputHeight;
// Calculate crop region (center crop)
let srcWidth = img.width;
let srcHeight = img.width / aspectRatio;
let srcX = 0;
let srcY = (img.height - srcHeight) / 2;
// Set canvas sizes
canvasNoDither.width = outputWidth;
canvasNoDither.height = outputHeight;
canvasDithered.width = outputWidth;
canvasDithered.height = outputHeight;
// Draw and process - no dithering
const ctxNo = canvasNoDither.getContext('2d')!;
ctxNo.drawImage(img, srcX, srcY, srcWidth, srcHeight, 0, 0, outputWidth, outputHeight);
const imageDataNo = ctxNo.getImageData(0, 0, outputWidth, outputHeight);
applyGreyscaleAndQuantize(imageDataNo.data, GREY_LEVELS);
ctxNo.putImageData(imageDataNo, 0, 0);
// Draw and process - with dithering
const ctxYes = canvasDithered.getContext('2d')!;
ctxYes.drawImage(img, srcX, srcY, srcWidth, srcHeight, 0, 0, outputWidth, outputHeight);
const imageDataYes = ctxYes.getImageData(0, 0, outputWidth, outputHeight);
applyGreyscaleQuantizeAndDither(imageDataYes.data, outputWidth, outputHeight, GREY_LEVELS);
ctxYes.putImageData(imageDataYes, 0, 0);
isProcessing = false;
isLoaded = true;
}
function applyGreyscaleAndQuantize(data: Uint8ClampedArray, levels: number): void {
const step = 255 / (levels - 1);
for (let i = 0; i < data.length; i += 4) {
// Luminosity greyscale
const grey = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114;
// Quantize
const quantized = Math.round(Math.round(grey / step) * step);
data[i] = data[i + 1] = data[i + 2] = quantized;
}
}
function applyGreyscaleQuantizeAndDither(
data: Uint8ClampedArray,
width: number,
height: number,
levels: number
): void {
const step = 255 / (levels - 1);
// Convert to greyscale float array for error diffusion
const grey = new Float32Array(width * height);
for (let i = 0; i < grey.length; i++) {
const idx = i * 4;
grey[i] = data[idx] * 0.299 + data[idx + 1] * 0.587 + data[idx + 2] * 0.114;
}
// Floyd-Steinberg dithering
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
const oldPixel = grey[idx];
const newPixel = Math.round(Math.round(oldPixel / step) * step);
grey[idx] = newPixel;
const error = oldPixel - newPixel;
// Distribute error to neighbors
if (x + 1 < width) grey[idx + 1] += (error * 7) / 16;
if (y + 1 < height) {
if (x > 0) grey[idx + width - 1] += (error * 3) / 16;
grey[idx + width] += (error * 5) / 16;
if (x + 1 < width) grey[idx + width + 1] += (error * 1) / 16;
}
}
}
// Write back to image data
for (let i = 0; i < grey.length; i++) {
const val = Math.max(0, Math.min(255, Math.round(grey[i])));
const idx = i * 4;
data[idx] = data[idx + 1] = data[idx + 2] = val;
}
}
function handleSliderStart(e: MouseEvent | TouchEvent): void {
e.preventDefault();
isDragging = true;
updateSliderPosition(e);
if (e.type === 'mousedown') {
window.addEventListener('mousemove', handleSliderMove);
window.addEventListener('mouseup', handleSliderEnd);
} else {
window.addEventListener('touchmove', handleSliderMove);
window.addEventListener('touchend', handleSliderEnd);
}
}
function handleSliderMove(e: MouseEvent | TouchEvent): void {
if (!isDragging) return;
updateSliderPosition(e);
}
function handleSliderEnd(): void {
isDragging = false;
window.removeEventListener('mousemove', handleSliderMove);
window.removeEventListener('mouseup', handleSliderEnd);
window.removeEventListener('touchmove', handleSliderMove);
window.removeEventListener('touchend', handleSliderEnd);
}
function updateSliderPosition(e: MouseEvent | TouchEvent): void {
if (!containerRef) return;
const rect = containerRef.getBoundingClientRect();
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const x = clientX - rect.left;
sliderPosition = Math.max(0, Math.min(100, (x / rect.width) * 100));
}
</script>
<div class="flex flex-col items-center {className}">
<div
class="relative rounded-lg border border-grey-200 shadow-sm overflow-hidden select-none bg-grey-100"
bind:this={containerRef}
>
{#if isProcessing}
<div class="w-[320px] h-[200px] flex items-center justify-center">
<p class="text-sm text-grey-400">Processing...</p>
</div>
{/if}
<!-- No dithering (background) -->
<canvas
bind:this={canvasNoDither}
class="block"
class:hidden={isProcessing}
></canvas>
<!-- With dithering (foreground, clipped) -->
<canvas
bind:this={canvasDithered}
class="absolute top-0 left-0"
class:hidden={isProcessing}
style="clip-path: inset(0 {100 - sliderPosition}% 0 0)"
></canvas>
<!-- Slider handle -->
{#if !isProcessing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="
absolute top-0 bottom-0 z-10
flex items-center justify-center
cursor-ew-resize
-translate-x-1/2
"
style="left: {sliderPosition}%"
onmousedown={handleSliderStart}
ontouchstart={handleSliderStart}
>
<div class="absolute top-0 bottom-0 w-0.5 bg-white shadow-md"></div>
<div
class="
relative flex items-center justify-center
w-8 h-8 bg-white rounded-full shadow-lg
border border-grey-200
{isDragging ? 'scale-110' : ''}
transition-transform duration-150
"
>
<GripVertical class="w-4 h-4 text-grey-400" />
</div>
</div>
<!-- Labels -->
<span
class="absolute bottom-2 left-2 px-1.5 py-0.5 bg-ink/60 text-white text-[10px] font-medium rounded pointer-events-none"
style="opacity: {sliderPosition > 20 ? 1 : 0}"
>
With Dithering
</span>
<span
class="absolute bottom-2 right-2 px-1.5 py-0.5 bg-ink/60 text-white text-[10px] font-medium rounded pointer-events-none"
style="opacity: {sliderPosition < 80 ? 1 : 0}"
>
No Dithering
</span>
{/if}
</div>
<p class="text-xs text-grey-400 mt-2">Drag to compare · 16 grey levels</p>
</div>

View File

@@ -0,0 +1,121 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
</script>
<svg
viewBox="-30 -20 340 440"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class={className}
style="overflow: visible;"
>
<!-- Device body with shadow -->
<defs>
<filter id="deviceShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="4" dy="12" stdDeviation="16" flood-color="#000" flood-opacity="0.25" />
</filter>
<linearGradient id="screenGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#f8f8f8" />
<stop offset="100%" stop-color="#e8e8e8" />
</linearGradient>
<clipPath id="screenClip">
<rect x="24" y="24" width="232" height="310" rx="2" />
</clipPath>
</defs>
<!-- Device outer shell -->
<rect
x="4"
y="4"
width="272"
height="372"
rx="16"
fill="#2a2a2a"
filter="url(#deviceShadow)"
/>
<!-- Device bezel -->
<rect
x="8"
y="8"
width="264"
height="364"
rx="12"
fill="#1a1a1a"
/>
<!-- Screen area -->
<rect
x="24"
y="24"
width="232"
height="310"
rx="2"
fill="url(#screenGradient)"
/>
<!-- Sample e-ink image (abstract landscape) -->
<g clip-path="url(#screenClip)">
<!-- Sky gradient -->
<rect x="24" y="24" width="232" height="155" fill="#d4d4d4" />
<!-- Sun/moon (behind mountains) -->
<circle cx="200" cy="70" r="25" fill="#f0f0f0" />
<!-- Mountains - back layer -->
<path
d="M24 160 L80 100 L140 140 L200 80 L256 130 L256 334 L24 334 Z"
fill="#a0a0a0"
/>
<!-- Mountains - front layer -->
<path
d="M24 180 L60 130 L120 170 L180 120 L240 160 L256 150 L256 334 L24 334 Z"
fill="#707070"
/>
<!-- Hills/foreground -->
<path
d="M24 200 Q80 180 140 210 Q200 240 256 200 L256 334 L24 334 Z"
fill="#505050"
/>
<!-- Trees silhouette -->
<g fill="#404040">
<path d="M40 210 L50 180 L60 210 Z" />
<path d="M55 215 L65 190 L75 215 Z" />
<path d="M200 225 L212 195 L224 225 Z" />
<path d="M215 230 L225 205 L235 230 Z" />
</g>
<!-- Subtle dither pattern overlay -->
<g opacity="0.08">
{#each Array(50) as _, i}
{#each Array(65) as _, j}
{#if (i + j) % 2 === 0}
<rect x={24 + j * 3.6} y={24 + i * 6.2} width="1" height="1" fill="#000" />
{/if}
{/each}
{/each}
</g>
</g>
<!-- Screen bezel inner edge highlight -->
<rect
x="24"
y="24"
width="232"
height="310"
rx="2"
fill="none"
stroke="#333"
stroke-width="1"
/>
<!-- Home button / indicator -->
<circle cx="140" cy="356" r="8" fill="#333" stroke="#444" stroke-width="1" />
</svg>

View File

@@ -1,3 +1,18 @@
// Components
export { default as DropZone } from './components/DropZone.svelte';
export { default as DeviceSelector } from './components/DeviceSelector.svelte';
export { default as CustomDeviceModal } from './components/CustomDeviceModal.svelte';
export { default as ImageCard } from './components/ImageCard.svelte';
export { default as ImageGrid } from './components/ImageGrid.svelte';
export { default as EditImageModal } from './components/EditImageModal.svelte';
export { default as EreaderMockup } from './components/EreaderMockup.svelte';
export { default as DitherComparison } from './components/DitherComparison.svelte';
// Stores
export { deviceStore } from './stores/device.svelte';
export { imagesStore } from './stores/images.svelte';
export { pipelineStore } from './stores/pipeline.svelte';
// Data
export { PRESET_DEVICES, DEFAULT_DEVICE, groupDevicesByBrand } from './data/devices';

View File

@@ -40,13 +40,15 @@ export interface CustomDeviceFormData {
outputFormat: OutputFormat;
}
export const SUPPORTED_FORMATS = ['image/jpeg', 'image/png', 'image/webp'] as const;
export const CONSTRAINTS = {
MAX_FILE_SIZE_MB: 25,
MAX_FILE_SIZE_BYTES: 25 * 1024 * 1024,
MIN_DIMENSION: 100,
MAX_DIMENSION: 5000,
MAX_DEVICE_NAME_LENGTH: 50,
SUPPORTED_FORMATS: ['image/jpeg', 'image/png', 'image/webp'],
SUPPORTED_FORMATS,
GREY_LEVEL_OPTIONS: [2, 4, 8, 16, 256],
OUTPUT_FORMAT_OPTIONS: ['png', 'jpeg']
} as const;

2
src/routes/+layout.ts Normal file
View File

@@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false;

View File

@@ -1,2 +1,584 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script lang="ts">
import {
Download,
Trash2,
Image,
Eye,
ImageIcon,
SlidersHorizontal,
ArrowDown,
Monitor,
Sliders,
ShieldCheck,
Layers,
SplitSquareHorizontal
} 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 editingImage = $state<ImageEntry | 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
$effect(() => {
const currentPresetId = pipelineStore.selectedPresetId;
if (currentPresetId !== previousPresetId && imagesStore.hasImages) {
previousPresetId = currentPresetId;
// Clear all image overrides and reprocess with new global config
for (const img of imagesStore.images) {
imagesStore.setPipelineOverride(img.id, null);
}
imagesStore.reprocessAll(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 {
const image = imagesStore.images.find((img) => img.id === id);
if (image) {
editingImage = image;
showEditModal = true;
}
}
function closeEditModal(): void {
showEditModal = false;
editingImage = null;
}
function handleApplyPipeline(imageId: string, config: PipelineConfig): void {
imagesStore.setPipelineOverride(imageId, config);
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>
</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-end">
<!-- 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 pb-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">
<label class="block text-xs font-medium text-grey-500 uppercase tracking-wide mb-1.5">
Device
</label>
<DeviceSelector onCustomClick={openCustomModal} />
</div>
<div class="w-full sm:w-auto sm:max-w-xs">
<label 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
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>
<button
type="button"
class="
flex items-center gap-1.5
px-3 py-1.5
text-grey-500
hover:text-error hover:bg-error-light
rounded-md
text-sm font-medium
transition-colors duration-150
"
onclick={handleClearAll}
>
<Trash2 class="w-4 h-4" />
Clear All
</button>
</div>
<!-- View mode toggle and download button -->
<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>
{#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>
<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="#converter" class="hover:text-accent transition-colors">Converter</a>
<a
href="https://github.com"
target="_blank"
rel="noopener noreferrer"
class="hover:text-accent transition-colors"
>
GitHub
</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}
/>

View File

@@ -3,7 +3,7 @@
@plugin '@tailwindcss/typography';
@theme {
/* Colors - e-ink inspired palette */
/* Colors - Minimal palette, e-ink inspired */
--color-ink: #1a1a1a;
--color-paper: #fafafa;
--color-grey-50: #f5f5f5;
@@ -22,7 +22,7 @@
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* Shadows */
/* Shadows - Subtle, paper-like */
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-card-hover: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.05);
--shadow-modal: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
@@ -33,6 +33,7 @@
--radius-lg: 0.75rem;
}
/* Base styles */
html {
scroll-behavior: smooth;
}
@@ -41,10 +42,9 @@ body {
@apply bg-paper text-ink font-sans antialiased;
}
/* Hero e-reader floating animation */
/* Floating animation for hero mockup */
@keyframes float {
0%,
100% {
0%, 100% {
transform: translateY(0) rotate(3deg);
}
50% {

BIN
static/cover.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB