feat: add image preview functionality with modal and thumbnail components

- Introduced `ImagePreviewModal` for displaying images with zoom and navigation controls.
- Added `ImagePreviewThumbnail` for quick access to the first image and indication of additional images.
- Integrated image preview components into the `KanbanCard`, allowing users to view images associated with features.
- Implemented keyboard navigation and mouse wheel zooming for enhanced user experience.
This commit is contained in:
Shirone
2026-01-11 12:10:23 +01:00
parent 6e4b611662
commit 93411cb60c
4 changed files with 642 additions and 14 deletions

View File

@@ -0,0 +1,435 @@
// @ts-nocheck
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import {
ChevronLeft,
ChevronRight,
X,
ZoomIn,
ZoomOut,
RotateCcw,
Download,
Image as ImageIcon,
} from 'lucide-react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { useAppStore, type FeatureImagePath } from '@/store/app-store';
interface ImagePreviewModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
imagePaths: FeatureImagePath[];
initialIndex?: number;
featureTitle?: string;
}
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.25;
export function ImagePreviewModal({
open,
onOpenChange,
imagePaths,
initialIndex = 0,
featureTitle,
}: ImagePreviewModalProps) {
const currentProject = useAppStore((s) => s.currentProject);
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [imageError, setImageError] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef({ x: 0, y: 0 });
const positionStartRef = useRef({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const imageCount = imagePaths.length;
const currentImage = imagePaths[currentIndex];
const hasMultipleImages = imageCount > 1;
const imageUrl = useMemo(() => {
if (!currentImage || !currentProject?.path) return null;
return getAuthenticatedImageUrl(currentImage.path, currentProject.path);
}, [currentImage, currentProject?.path]);
// Reset state when opening modal or changing image
useEffect(() => {
if (open) {
setCurrentIndex(initialIndex);
setZoom(1);
setPosition({ x: 0, y: 0 });
setIsLoading(true);
setImageError(false);
}
}, [open, initialIndex]);
// Reset position when changing images
useEffect(() => {
setZoom(1);
setPosition({ x: 0, y: 0 });
setIsLoading(true);
setImageError(false);
}, [currentIndex]);
const handlePrevious = useCallback(() => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : imageCount - 1));
}, [imageCount]);
const handleNext = useCallback(() => {
setCurrentIndex((prev) => (prev < imageCount - 1 ? prev + 1 : 0));
}, [imageCount]);
const handleZoomIn = useCallback(() => {
setZoom((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM));
}, []);
const handleZoomOut = useCallback(() => {
setZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM));
}, []);
const handleResetZoom = useCallback(() => {
setZoom(1);
setPosition({ x: 0, y: 0 });
}, []);
const handleDownload = useCallback(async () => {
if (!imageUrl || !currentImage) return;
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = currentImage.filename || 'image';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download image:', error);
}
}, [imageUrl, currentImage]);
// Keyboard navigation
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
if (hasMultipleImages) handlePrevious();
break;
case 'ArrowRight':
e.preventDefault();
if (hasMultipleImages) handleNext();
break;
case 'Escape':
e.preventDefault();
onOpenChange(false);
break;
case '+':
case '=':
e.preventDefault();
handleZoomIn();
break;
case '-':
e.preventDefault();
handleZoomOut();
break;
case '0':
e.preventDefault();
handleResetZoom();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
open,
hasMultipleImages,
handlePrevious,
handleNext,
handleZoomIn,
handleZoomOut,
handleResetZoom,
onOpenChange,
]);
// Mouse wheel zoom
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
setZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM));
}, []);
// Pan/drag functionality
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (zoom <= 1) return;
e.preventDefault();
setIsDragging(true);
dragStartRef.current = { x: e.clientX, y: e.clientY };
positionStartRef.current = { ...position };
},
[zoom, position]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isDragging) return;
const deltaX = e.clientX - dragStartRef.current.x;
const deltaY = e.clientY - dragStartRef.current.y;
setPosition({
x: positionStartRef.current.x + deltaX,
y: positionStartRef.current.y + deltaY,
});
},
[isDragging]
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
const handleImageLoad = useCallback(() => {
setIsLoading(false);
setImageError(false);
}, []);
const handleImageError = useCallback(() => {
setIsLoading(false);
setImageError(true);
}, []);
if (imageCount === 0) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-[95vw] max-h-[95vh] w-full h-full p-0 bg-black/95 border-none gap-0"
showCloseButton={false}
>
{/* Hidden title for accessibility */}
<DialogTitle className="sr-only">
{featureTitle ? `Images for ${featureTitle}` : 'Image Preview'}
</DialogTitle>
<DialogDescription className="sr-only">
Image {currentIndex + 1} of {imageCount}
{currentImage?.filename && `: ${currentImage.filename}`}
</DialogDescription>
{/* Header with controls */}
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4 bg-gradient-to-b from-black/80 to-transparent">
{/* Image counter */}
<div className="flex items-center gap-2 text-white/90">
<span className="text-sm font-medium">
{currentIndex + 1} / {imageCount}
</span>
{currentImage?.filename && (
<span className="text-sm text-white/60 truncate max-w-[200px]">
{currentImage.filename}
</span>
)}
</div>
{/* Controls */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="text-white/80 hover:text-white hover:bg-white/10 h-8 w-8"
onClick={handleZoomOut}
disabled={zoom <= MIN_ZOOM}
aria-label="Zoom out"
>
<ZoomOut className="w-4 h-4" />
</Button>
<span className="text-white/80 text-xs min-w-[40px] text-center">
{Math.round(zoom * 100)}%
</span>
<Button
variant="ghost"
size="icon"
className="text-white/80 hover:text-white hover:bg-white/10 h-8 w-8"
onClick={handleZoomIn}
disabled={zoom >= MAX_ZOOM}
aria-label="Zoom in"
>
<ZoomIn className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-white/80 hover:text-white hover:bg-white/10 h-8 w-8"
onClick={handleResetZoom}
aria-label="Reset zoom"
>
<RotateCcw className="w-4 h-4" />
</Button>
<div className="w-px h-4 bg-white/20 mx-1" />
<Button
variant="ghost"
size="icon"
className="text-white/80 hover:text-white hover:bg-white/10 h-8 w-8"
onClick={handleDownload}
aria-label="Download image"
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-white/80 hover:text-white hover:bg-white/10 h-8 w-8"
onClick={() => onOpenChange(false)}
aria-label="Close"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* Navigation buttons */}
{hasMultipleImages && (
<>
<Button
variant="ghost"
size="icon"
className={cn(
'absolute left-4 top-1/2 -translate-y-1/2 z-10',
'w-10 h-10 rounded-full',
'bg-black/50 hover:bg-black/70 text-white/80 hover:text-white',
'transition-all duration-200'
)}
onClick={handlePrevious}
aria-label="Previous image"
>
<ChevronLeft className="w-6 h-6" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
'absolute right-4 top-1/2 -translate-y-1/2 z-10',
'w-10 h-10 rounded-full',
'bg-black/50 hover:bg-black/70 text-white/80 hover:text-white',
'transition-all duration-200'
)}
onClick={handleNext}
aria-label="Next image"
>
<ChevronRight className="w-6 h-6" />
</Button>
</>
)}
{/* Image container */}
<div
ref={containerRef}
className={cn(
'flex-1 flex items-center justify-center overflow-hidden',
'select-none',
zoom > 1 ? 'cursor-grab' : 'cursor-default',
isDragging && 'cursor-grabbing'
)}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Loading state */}
{isLoading && !imageError && (
<div className="absolute inset-0 flex items-center justify-center">
<ImageIcon className="w-12 h-12 text-white/40 animate-pulse" />
</div>
)}
{/* Error state */}
{imageError && (
<div className="flex flex-col items-center justify-center gap-2 text-white/60">
<ImageIcon className="w-12 h-12" />
<span>Failed to load image</span>
</div>
)}
{/* Image */}
{imageUrl && !imageError && (
<img
src={imageUrl}
alt={currentImage?.filename || 'Feature image'}
className={cn(
'max-w-full max-h-full object-contain transition-opacity duration-200',
isLoading && 'opacity-0'
)}
style={{
transform: `scale(${zoom}) translate(${position.x / zoom}px, ${position.y / zoom}px)`,
transformOrigin: 'center center',
}}
onLoad={handleImageLoad}
onError={handleImageError}
draggable={false}
/>
)}
</div>
{/* Thumbnail strip for multiple images */}
{hasMultipleImages && (
<div className="absolute bottom-0 left-0 right-0 z-10 p-4 bg-gradient-to-t from-black/80 to-transparent">
<div className="flex items-center justify-center gap-2 overflow-x-auto py-2">
{imagePaths.map((img, index) => {
const thumbUrl = currentProject?.path
? getAuthenticatedImageUrl(img.path, currentProject.path)
: null;
return (
<button
key={img.id}
className={cn(
'w-14 h-14 rounded-md overflow-hidden flex-shrink-0',
'border-2 transition-all duration-200',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-white',
index === currentIndex
? 'border-white opacity-100'
: 'border-transparent opacity-60 hover:opacity-100'
)}
onClick={() => setCurrentIndex(index)}
aria-label={`View image ${index + 1}`}
aria-current={index === currentIndex}
>
{thumbUrl ? (
<img
src={thumbUrl}
alt={img.filename || `Image ${index + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full bg-white/10 flex items-center justify-center">
<ImageIcon className="w-4 h-4 text-white/40" />
</div>
)}
</button>
);
})}
</div>
</div>
)}
{/* Keyboard shortcuts hint */}
<div className="absolute bottom-4 right-4 z-10 text-white/40 text-xs hidden md:block">
<span className="bg-black/50 px-2 py-1 rounded">
Use arrow keys to navigate, +/- to zoom
</span>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,154 @@
// @ts-nocheck
import React, { useState, useCallback, useMemo } from 'react';
import { Image as ImageIcon, Images } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { useAppStore, type FeatureImagePath } from '@/store/app-store';
interface ImagePreviewThumbnailProps {
imagePaths: FeatureImagePath[];
featureId: string;
onImageClick: (index: number) => void;
className?: string;
}
export function ImagePreviewThumbnail({
imagePaths,
featureId,
onImageClick,
className,
}: ImagePreviewThumbnailProps) {
const currentProject = useAppStore((s) => s.currentProject);
const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const imageCount = imagePaths.length;
const firstImage = imagePaths[0];
const additionalCount = imageCount - 1;
const imageUrl = useMemo(() => {
if (!firstImage || !currentProject?.path) return null;
return getAuthenticatedImageUrl(firstImage.path, currentProject.path);
}, [firstImage, currentProject?.path]);
const handleImageLoad = useCallback(() => {
setIsLoading(false);
setImageError(false);
}, []);
const handleImageError = useCallback(() => {
setIsLoading(false);
setImageError(true);
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onImageClick(0);
},
[onImageClick]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
onImageClick(0);
}
},
[onImageClick]
);
if (!imageUrl || imageCount === 0) {
return null;
}
return (
<div
className={cn(
'relative group cursor-pointer',
'w-full aspect-[16/9] max-h-[100px] rounded-lg overflow-hidden',
'bg-muted/50 border border-border/50',
'hover:border-border hover:shadow-sm transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
className
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
onPointerDown={(e) => e.stopPropagation()}
role="button"
tabIndex={0}
aria-label={`View ${imageCount} attached image${imageCount > 1 ? 's' : ''}`}
data-testid={`image-thumbnail-${featureId}`}
>
{/* Loading state */}
{isLoading && !imageError && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/80">
<ImageIcon className="w-5 h-5 text-muted-foreground animate-pulse" />
</div>
)}
{/* Error state */}
{imageError && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/80 gap-1">
<ImageIcon className="w-5 h-5 text-muted-foreground/60" />
<span className="text-[10px] text-muted-foreground/60">Failed to load</span>
</div>
)}
{/* Thumbnail image */}
{!imageError && (
<img
src={imageUrl}
alt={firstImage.filename || 'Feature image'}
className={cn(
'w-full h-full object-cover',
'transition-transform duration-200 group-hover:scale-105',
isLoading && 'opacity-0'
)}
onLoad={handleImageLoad}
onError={handleImageError}
loading="lazy"
decoding="async"
/>
)}
{/* Multiple images indicator */}
{additionalCount > 0 && !imageError && (
<div
className={cn(
'absolute bottom-1.5 right-1.5',
'flex items-center gap-1 px-1.5 py-0.5',
'bg-black/70 backdrop-blur-sm rounded-md',
'text-[10px] font-medium text-white'
)}
>
<Images className="w-3 h-3" />
<span>+{additionalCount}</span>
</div>
)}
{/* Hover overlay */}
<div
className={cn(
'absolute inset-0 bg-black/0 group-hover:bg-black/10',
'transition-colors duration-200',
'flex items-center justify-center'
)}
>
<span
className={cn(
'opacity-0 group-hover:opacity-100',
'text-white text-xs font-medium',
'bg-black/50 px-2 py-1 rounded-md backdrop-blur-sm',
'transition-opacity duration-200'
)}
>
Click to view
</span>
</div>
</div>
);
}

View File

@@ -5,3 +5,5 @@ export { CardContentSections } from './card-content-sections';
export { CardHeaderSection } from './card-header';
export { KanbanCard } from './kanban-card';
export { SummaryDialog } from './summary-dialog';
export { ImagePreviewThumbnail } from './image-preview-thumbnail';
export { ImagePreviewModal } from './image-preview-modal';

View File

@@ -1,5 +1,5 @@
// @ts-nocheck
import React, { memo, useLayoutEffect, useState } from 'react';
import React, { memo, useLayoutEffect, useState, useCallback } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
@@ -10,6 +10,8 @@ import { CardHeaderSection } from './card-header';
import { CardContentSections } from './card-content-sections';
import { AgentInfoPanel } from './agent-info-panel';
import { CardActions } from './card-actions';
import { ImagePreviewThumbnail } from './image-preview-thumbnail';
import { ImagePreviewModal } from './image-preview-modal';
function getCardBorderStyle(enabled: boolean, opacity: number): React.CSSProperties {
if (!enabled) {
@@ -99,6 +101,17 @@ export const KanbanCard = memo(function KanbanCard({
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
const [isLifted, setIsLifted] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false);
const [imageModalIndex, setImageModalIndex] = useState(0);
// Get image paths from feature
const imagePaths = feature.imagePaths ?? [];
const hasImages = imagePaths.length > 0;
const handleImageClick = useCallback((index: number) => {
setImageModalIndex(index);
setImageModalOpen(true);
}, []);
useLayoutEffect(() => {
if (isOverlay) {
@@ -210,6 +223,17 @@ export const KanbanCard = memo(function KanbanCard({
{/* Content Sections */}
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
{/* Image Preview Thumbnail */}
{hasImages && !isOverlay && (
<div className="mb-2">
<ImagePreviewThumbnail
imagePaths={imagePaths}
featureId={feature.id}
onImageClick={handleImageClick}
/>
</div>
)}
{/* Agent Info Panel */}
<AgentInfoPanel
feature={feature}
@@ -242,19 +266,32 @@ export const KanbanCard = memo(function KanbanCard({
);
return (
<div
ref={setNodeRef}
style={dndStyle}
{...attributes}
{...(isDraggable ? listeners : {})}
className={wrapperClasses}
data-testid={`kanban-card-${feature.id}`}
>
{isCurrentAutoTask ? (
<div className="animated-border-wrapper">{renderCardContent()}</div>
) : (
renderCardContent()
<>
<div
ref={setNodeRef}
style={dndStyle}
{...attributes}
{...(isDraggable ? listeners : {})}
className={wrapperClasses}
data-testid={`kanban-card-${feature.id}`}
>
{isCurrentAutoTask ? (
<div className="animated-border-wrapper">{renderCardContent()}</div>
) : (
renderCardContent()
)}
</div>
{/* Image Preview Modal */}
{hasImages && (
<ImagePreviewModal
open={imageModalOpen}
onOpenChange={setImageModalOpen}
imagePaths={imagePaths}
initialIndex={imageModalIndex}
featureTitle={feature.title}
/>
)}
</div>
</>
);
});