feat: add image display components

- ImageCard: image preview with comparison slider
- ImageGrid: responsive grid layout for cards
- ImagePreview: live preview with pipeline processing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 18:52:35 -04:00
parent a777ab364d
commit e9406bb25f
3 changed files with 391 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
<script lang="ts">
import { X, Download, Pencil, AlertCircle, Loader2, GripVertical } from 'lucide-svelte';
import type { ImageEntry, Device } from '$lib/types';
import { downloadImage } from '$lib/processing/pipeline';
export type ViewMode = 'compare' | 'original' | 'processed';
interface Props {
image: ImageEntry;
device: Device;
onRemove: (id: string) => void;
onEdit?: (id: string) => void;
viewMode?: ViewMode;
}
let { image, device, onRemove, onEdit, viewMode = 'compare' }: Props = $props();
// Comparison slider state
let manualSliderPosition = $state(50); // percentage from left
// Effective slider position based on view mode
const sliderPosition = $derived(
viewMode === 'original' ? 100 : viewMode === 'processed' ? 0 : manualSliderPosition
);
let isDragging = $state(false);
let containerRef: HTMLDivElement;
const dimensionsText = $derived(() => {
if (!image.originalDimensions) return '';
const orig = image.originalDimensions;
return `${orig.width} × ${orig.height}${device.width} × ${device.height}`;
});
function handleDownload(): void {
downloadImage(image, device);
}
function handleRemove(): void {
onRemove(image.id);
}
function handleEdit(): void {
onEdit?.(image.id);
}
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 || viewMode !== 'compare') return;
const rect = containerRef.getBoundingClientRect();
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const x = clientX - rect.left;
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
manualSliderPosition = percentage;
}
</script>
<div
class="
relative
bg-white
border rounded-lg
overflow-hidden
transition-shadow duration-200
{image.status === 'error'
? 'border-error border-l-4 shadow-[--shadow-card]'
: 'border-grey-200 shadow-[--shadow-card] hover:shadow-[--shadow-card-hover]'}
"
>
<!-- Remove button -->
<button
type="button"
class="
absolute top-2 right-2 z-10
p-1.5
bg-white/80 backdrop-blur-sm
text-grey-400
hover:text-grey-600 hover:bg-white
rounded-md
transition-colors duration-150
"
onclick={handleRemove}
aria-label="Remove image"
>
<X class="w-4 h-4" />
</button>
<!-- Preview area -->
<div class="aspect-[3/4] bg-grey-50 relative" bind:this={containerRef}>
{#if image.status === 'error'}
<!-- Error state -->
<div class="absolute inset-0 flex flex-col items-center justify-center p-4 bg-error-light">
<AlertCircle class="w-8 h-8 text-error mb-2" />
<p class="text-sm font-medium text-error text-center">Processing failed</p>
<p class="text-xs text-error/80 text-center mt-1">{image.error}</p>
</div>
{:else if image.status === 'pending' || image.status === 'processing'}
<!-- Loading state -->
<div class="absolute inset-0 flex flex-col items-center justify-center">
<Loader2 class="w-8 h-8 text-grey-400 animate-spin" />
<p class="text-sm text-grey-400 mt-2">Processing...</p>
</div>
{:else if image.originalDataUrl && image.processedDataUrl}
<!-- Image comparison slider -->
<div class="absolute inset-0 select-none">
<!-- Processed image (background layer) -->
<img
src={image.processedDataUrl}
alt="Processed"
class="absolute inset-0 w-full h-full object-cover"
draggable="false"
/>
<!-- Original image (foreground layer, clipped from right) -->
<img
src={image.originalDataUrl}
alt="Original"
class="absolute inset-0 w-full h-full object-cover"
style="clip-path: inset(0 {100 - sliderPosition}% 0 0)"
draggable="false"
/>
<!-- Slider handle (only in compare mode) -->
{#if viewMode === 'compare'}
<!-- 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}
>
<!-- Vertical line -->
<div class="absolute top-0 bottom-0 w-0.5 bg-white shadow-md"></div>
<!-- Handle grip -->
<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>
{/if}
<!-- Labels -->
<span
class="absolute bottom-1 left-1 px-1.5 py-0.5 bg-ink/60 text-white text-[10px] font-medium rounded pointer-events-none"
style="opacity: {sliderPosition > 15 ? 1 : 0}"
>
Original
</span>
<span
class="absolute bottom-1 right-1 px-1.5 py-0.5 bg-ink/60 text-white text-[10px] font-medium rounded pointer-events-none"
style="opacity: {sliderPosition < 85 ? 1 : 0}"
>
Processed
</span>
</div>
{/if}
</div>
<!-- Info area -->
<div class="p-3">
<p class="text-sm font-medium text-ink truncate" title={image.filename}>
{image.filename}
</p>
{#if image.status === 'complete'}
<p class="text-xs text-grey-400 font-mono mt-0.5">
{dimensionsText()}
</p>
{/if}
{#if image.status === 'complete'}
<div class="mt-2 flex gap-2">
<button
type="button"
class="
flex-1
flex items-center justify-center gap-1.5
px-3 py-1.5
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={handleDownload}
>
<Download class="w-4 h-4" />
Download
</button>
{#if onEdit}
<button
type="button"
class="
flex items-center justify-center
px-3 py-1.5
bg-white text-ink
border border-grey-200
rounded-md
text-sm font-medium
hover:bg-grey-50 hover:border-grey-300
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
transition-colors duration-150
"
onclick={handleEdit}
aria-label="Edit image"
>
<Pencil class="w-4 h-4" />
</button>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { ImageEntry, Device } from '$lib/types';
import ImageCard, { type ViewMode } from './ImageCard.svelte';
interface Props {
images: ImageEntry[];
device: Device;
onRemove: (id: string) => void;
onEdit?: (id: string) => void;
viewMode?: ViewMode;
}
let { images, device, onRemove, onEdit, viewMode = 'compare' }: Props = $props();
</script>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{#each images as image (image.id)}
<ImageCard {image} {device} {onRemove} {onEdit} {viewMode} />
{/each}
</div>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { Loader2 } from 'lucide-svelte';
import type { ImageEntry, Device, PipelineConfig } from '$lib/types';
import { processImageWithPipeline } from '$lib/processing/pipeline';
interface Props {
image: ImageEntry;
device: Device;
config: PipelineConfig;
onPreviewUpdate: (dataUrl: string) => void;
onProcessingChange: (processing: boolean) => void;
}
let { image, device, config, onPreviewUpdate, onProcessingChange }: Props = $props();
let previewDataUrl = $state<string | null>(null);
let isProcessing = $state(false);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Track config changes and trigger debounced processing
$effect(() => {
// Access config to create dependency
const _ = JSON.stringify(config);
// Clear existing timer
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Debounce processing
debounceTimer = setTimeout(() => {
processPreview();
}, 150);
return () => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
};
});
async function processPreview(): Promise<void> {
if (!image.file) return;
isProcessing = true;
onProcessingChange(true);
try {
const { dataUrl } = await processImageWithPipeline(image.file, device, config);
previewDataUrl = dataUrl;
onPreviewUpdate(dataUrl);
} catch (err) {
console.error('Preview processing failed:', err);
} finally {
isProcessing = false;
onProcessingChange(false);
}
}
// Initialize with current processed image
$effect(() => {
if (image.processedDataUrl && !previewDataUrl) {
previewDataUrl = image.processedDataUrl;
}
});
</script>
<div class="flex flex-col items-center gap-4">
<!-- Preview container -->
<div class="relative w-full max-w-md aspect-[3/4] bg-grey-100 rounded-lg overflow-hidden shadow-inner">
{#if previewDataUrl}
<img
src={previewDataUrl}
alt="Preview"
class="absolute inset-0 w-full h-full object-contain"
/>
{:else if image.originalDataUrl}
<img
src={image.originalDataUrl}
alt="Original"
class="absolute inset-0 w-full h-full object-contain opacity-50"
/>
{/if}
<!-- Processing overlay -->
{#if isProcessing}
<div class="absolute inset-0 flex items-center justify-center bg-white/70">
<div class="flex flex-col items-center gap-2">
<Loader2 class="w-8 h-8 text-accent animate-spin" />
<span class="text-sm text-grey-500">Processing...</span>
</div>
</div>
{/if}
</div>
<!-- Image info -->
<div class="text-center">
<p class="text-sm font-medium text-ink">{image.filename}</p>
{#if image.originalDimensions}
<p class="text-xs text-grey-400 font-mono mt-1">
{image.originalDimensions.width} × {image.originalDimensions.height}
{device.width} × {device.height}
</p>
{/if}
</div>
<!-- Original vs Preview toggle (future enhancement) -->
<!-- <div class="flex items-center gap-2">
<button class="text-sm text-grey-500 hover:text-ink">Original</button>
<button class="text-sm text-accent font-medium">Preview</button>
</div> -->
</div>