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