mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-05 09:33:07 +00:00
Compare commits
1 Commits
v0.11.0
...
feat/image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93411cb60c |
@@ -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 { CardHeaderSection } from './card-header';
|
||||||
export { KanbanCard } from './kanban-card';
|
export { KanbanCard } from './kanban-card';
|
||||||
export { SummaryDialog } from './summary-dialog';
|
export { SummaryDialog } from './summary-dialog';
|
||||||
|
export { ImagePreviewThumbnail } from './image-preview-thumbnail';
|
||||||
|
export { ImagePreviewModal } from './image-preview-modal';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import React, { memo, useLayoutEffect, useState } from 'react';
|
import React, { memo, useLayoutEffect, useState, useCallback } from 'react';
|
||||||
import { useDraggable } from '@dnd-kit/core';
|
import { useDraggable } from '@dnd-kit/core';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -10,6 +10,8 @@ import { CardHeaderSection } from './card-header';
|
|||||||
import { CardContentSections } from './card-content-sections';
|
import { CardContentSections } from './card-content-sections';
|
||||||
import { AgentInfoPanel } from './agent-info-panel';
|
import { AgentInfoPanel } from './agent-info-panel';
|
||||||
import { CardActions } from './card-actions';
|
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 {
|
function getCardBorderStyle(enabled: boolean, opacity: number): React.CSSProperties {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
@@ -99,6 +101,17 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
}: KanbanCardProps) {
|
}: KanbanCardProps) {
|
||||||
const { useWorktrees } = useAppStore();
|
const { useWorktrees } = useAppStore();
|
||||||
const [isLifted, setIsLifted] = useState(false);
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (isOverlay) {
|
if (isOverlay) {
|
||||||
@@ -210,6 +223,17 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
{/* Content Sections */}
|
{/* Content Sections */}
|
||||||
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
|
<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 */}
|
{/* Agent Info Panel */}
|
||||||
<AgentInfoPanel
|
<AgentInfoPanel
|
||||||
feature={feature}
|
feature={feature}
|
||||||
@@ -242,6 +266,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={dndStyle}
|
style={dndStyle}
|
||||||
@@ -256,5 +281,17 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
renderCardContent()
|
renderCardContent()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Image Preview Modal */}
|
||||||
|
{hasImages && (
|
||||||
|
<ImagePreviewModal
|
||||||
|
open={imageModalOpen}
|
||||||
|
onOpenChange={setImageModalOpen}
|
||||||
|
imagePaths={imagePaths}
|
||||||
|
initialIndex={imageModalIndex}
|
||||||
|
featureTitle={feature.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user