diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/image-preview-modal.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/image-preview-modal.tsx new file mode 100644 index 000000000..23175f73e --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/kanban-card/image-preview-modal.tsx @@ -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(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 ( + + + {/* Hidden title for accessibility */} + + {featureTitle ? `Images for ${featureTitle}` : 'Image Preview'} + + + Image {currentIndex + 1} of {imageCount} + {currentImage?.filename && `: ${currentImage.filename}`} + + + {/* Header with controls */} +
+ {/* Image counter */} +
+ + {currentIndex + 1} / {imageCount} + + {currentImage?.filename && ( + + {currentImage.filename} + + )} +
+ + {/* Controls */} +
+ + + {Math.round(zoom * 100)}% + + + +
+ + +
+
+ + {/* Navigation buttons */} + {hasMultipleImages && ( + <> + + + + )} + + {/* Image container */} +
1 ? 'cursor-grab' : 'cursor-default', + isDragging && 'cursor-grabbing' + )} + onWheel={handleWheel} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onMouseLeave={handleMouseUp} + > + {/* Loading state */} + {isLoading && !imageError && ( +
+ +
+ )} + + {/* Error state */} + {imageError && ( +
+ + Failed to load image +
+ )} + + {/* Image */} + {imageUrl && !imageError && ( + {currentImage?.filename + )} +
+ + {/* Thumbnail strip for multiple images */} + {hasMultipleImages && ( +
+
+ {imagePaths.map((img, index) => { + const thumbUrl = currentProject?.path + ? getAuthenticatedImageUrl(img.path, currentProject.path) + : null; + + return ( + + ); + })} +
+
+ )} + + {/* Keyboard shortcuts hint */} +
+ + Use arrow keys to navigate, +/- to zoom + +
+ +
+ ); +} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/image-preview-thumbnail.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/image-preview-thumbnail.tsx new file mode 100644 index 000000000..3adef0ca1 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/kanban-card/image-preview-thumbnail.tsx @@ -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 ( +
e.stopPropagation()} + role="button" + tabIndex={0} + aria-label={`View ${imageCount} attached image${imageCount > 1 ? 's' : ''}`} + data-testid={`image-thumbnail-${featureId}`} + > + {/* Loading state */} + {isLoading && !imageError && ( +
+ +
+ )} + + {/* Error state */} + {imageError && ( +
+ + Failed to load +
+ )} + + {/* Thumbnail image */} + {!imageError && ( + {firstImage.filename + )} + + {/* Multiple images indicator */} + {additionalCount > 0 && !imageError && ( +
+ + +{additionalCount} +
+ )} + + {/* Hover overlay */} +
+ + Click to view + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/index.ts b/apps/ui/src/components/views/board-view/components/kanban-card/index.ts index a8b7a36a5..e4de5e2bd 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/index.ts +++ b/apps/ui/src/components/views/board-view/components/kanban-card/index.ts @@ -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'; diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index 6f22e87e7..ad69f4d52 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -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 */} + {/* Image Preview Thumbnail */} + {hasImages && !isOverlay && ( +
+ +
+ )} + {/* Agent Info Panel */} - {isCurrentAutoTask ? ( -
{renderCardContent()}
- ) : ( - renderCardContent() + <> +
+ {isCurrentAutoTask ? ( +
{renderCardContent()}
+ ) : ( + renderCardContent() + )} +
+ + {/* Image Preview Modal */} + {hasImages && ( + )} - + ); });