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:
258
src/lib/components/ImageCard.svelte
Normal file
258
src/lib/components/ImageCard.svelte
Normal 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>
|
||||||
20
src/lib/components/ImageGrid.svelte
Normal file
20
src/lib/components/ImageGrid.svelte
Normal 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>
|
||||||
113
src/lib/components/ImagePreview.svelte
Normal file
113
src/lib/components/ImagePreview.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user