mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user