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,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>