181 lines
6.4 KiB
TypeScript
181 lines
6.4 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { ChevronLeftIcon, ImagesIcon, XIcon } from 'lucide-react';
|
|
import { AnimatePresence, motion } from 'motion/react';
|
|
import { type GeneratedImage, useGeneratedImages } from '@/hooks/useGeneratedImages';
|
|
import { cn } from '@/lib/shadcn/utils';
|
|
|
|
const MotionPanel = motion.create('div');
|
|
const MotionOverlay = motion.create('div');
|
|
|
|
interface ThumbnailProps {
|
|
image: GeneratedImage;
|
|
onClick: () => void;
|
|
}
|
|
|
|
function Thumbnail({ image, onClick }: ThumbnailProps) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className="group border-input/50 bg-muted hover:border-foreground/20 focus-visible:ring-ring relative aspect-square overflow-hidden rounded-lg border transition-all hover:shadow-md focus-visible:ring-2 focus-visible:outline-none"
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={image.imageUrl}
|
|
alt={image.prompt}
|
|
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface FullImageViewProps {
|
|
image: GeneratedImage;
|
|
onBack: () => void;
|
|
}
|
|
|
|
function FullImageView({ image, onBack }: FullImageViewProps) {
|
|
return (
|
|
<MotionPanel
|
|
key="full"
|
|
initial={{ opacity: 0, x: 24 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 24 }}
|
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
|
className="flex h-full flex-col"
|
|
>
|
|
<button
|
|
onClick={onBack}
|
|
className="text-muted-foreground hover:text-foreground mb-3 flex items-center gap-1 text-sm transition-colors"
|
|
>
|
|
<ChevronLeftIcon className="size-4" />
|
|
All images
|
|
</button>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={image.imageUrl} alt={image.prompt} className="w-full rounded-xl object-contain" />
|
|
{image.prompt && <p className="text-muted-foreground mt-3 text-sm">{image.prompt}</p>}
|
|
</MotionPanel>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Floating gallery button + slide-in panel for all agent-generated images.
|
|
* The button appears once at least one image has been generated.
|
|
*/
|
|
export function ImageGallery() {
|
|
const images = useGeneratedImages();
|
|
const [open, setOpen] = useState(false);
|
|
const [selected, setSelected] = useState<GeneratedImage | null>(null);
|
|
|
|
if (images.length === 0) return null;
|
|
|
|
return (
|
|
<>
|
|
{/* Floating trigger button */}
|
|
<AnimatePresence>
|
|
{!open && (
|
|
<MotionPanel
|
|
key="trigger"
|
|
initial={{ opacity: 0, scale: 0.85 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.85 }}
|
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
|
className="fixed right-4 bottom-36 z-40 md:bottom-44"
|
|
>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
aria-label="View generated images"
|
|
className={cn(
|
|
'relative flex items-center justify-center rounded-full p-3',
|
|
'bg-background border-input/50 border shadow-lg',
|
|
'hover:bg-accent focus-visible:ring-ring transition-colors focus-visible:ring-2 focus-visible:outline-none'
|
|
)}
|
|
>
|
|
<ImagesIcon className="text-foreground size-5" />
|
|
<span className="bg-primary text-primary-foreground absolute -top-1.5 -right-1.5 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold">
|
|
{images.length}
|
|
</span>
|
|
</button>
|
|
</MotionPanel>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Gallery panel + backdrop */}
|
|
<AnimatePresence>
|
|
{open && (
|
|
<>
|
|
<MotionOverlay
|
|
key="overlay"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"
|
|
onClick={() => {
|
|
setSelected(null);
|
|
setOpen(false);
|
|
}}
|
|
/>
|
|
|
|
<MotionPanel
|
|
key="panel"
|
|
initial={{ x: '100%' }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: '100%' }}
|
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
|
className="border-input/50 bg-background fixed top-0 right-0 bottom-0 z-[60] flex w-80 flex-col overflow-hidden border-l shadow-2xl md:top-16"
|
|
>
|
|
{/* Header */}
|
|
<div className="border-input/50 flex items-center justify-between border-b px-4 py-3">
|
|
<div>
|
|
<h2 className="text-sm font-semibold">Generated images</h2>
|
|
<p className="text-muted-foreground text-xs">
|
|
{images.length} {images.length === 1 ? 'image' : 'images'}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setSelected(null);
|
|
setOpen(false);
|
|
}}
|
|
aria-label="Close gallery"
|
|
className="hover:bg-accent focus-visible:ring-ring rounded-full p-1.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
>
|
|
<XIcon className="text-muted-foreground size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
<AnimatePresence mode="wait">
|
|
{selected ? (
|
|
<FullImageView
|
|
key={selected.id}
|
|
image={selected}
|
|
onBack={() => setSelected(null)}
|
|
/>
|
|
) : (
|
|
<MotionPanel
|
|
key="grid"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="grid grid-cols-2 gap-2"
|
|
>
|
|
{[...images].reverse().map((img) => (
|
|
<Thumbnail key={img.id} image={img} onClick={() => setSelected(img)} />
|
|
))}
|
|
</MotionPanel>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</MotionPanel>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|