97 lines
3.0 KiB
TypeScript
97 lines
3.0 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { 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');
|
|
|
|
interface ImageCardProps {
|
|
image: GeneratedImage;
|
|
onDismiss: () => void;
|
|
}
|
|
|
|
function ImageCard({ image, onDismiss }: ImageCardProps) {
|
|
const src = image.imageUrl;
|
|
|
|
return (
|
|
<MotionPanel
|
|
key={image.id}
|
|
layout
|
|
initial={{ opacity: 0, scale: 0.92, y: 8 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.92, y: 8 }}
|
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
|
className="bg-background border-input/50 relative overflow-hidden rounded-xl border shadow-xl"
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={src} alt={image.prompt} className="block max-h-[360px] w-full object-contain" />
|
|
{image.prompt && (
|
|
<div className="bg-background/80 px-3 py-1.5 backdrop-blur-sm">
|
|
<p className="text-muted-foreground line-clamp-2 text-xs">{image.prompt}</p>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={onDismiss}
|
|
className={cn(
|
|
'bg-background/70 hover:bg-background absolute top-2 right-2 rounded-full p-1 backdrop-blur-sm transition-colors'
|
|
)}
|
|
aria-label="Dismiss image"
|
|
>
|
|
<XIcon className="text-muted-foreground size-3.5" />
|
|
</button>
|
|
</MotionPanel>
|
|
);
|
|
}
|
|
|
|
interface GeneratedImagePanelProps {
|
|
chatOpen?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Listens for images generated by the agent and shows the most recent one.
|
|
* When chat is open, repositions to the top-right corner to avoid covering the transcript.
|
|
*
|
|
* HACK HERE: swap for a gallery view, a fullscreen modal, download button, etc.
|
|
*/
|
|
export function GeneratedImagePanel({ chatOpen = false }: GeneratedImagePanelProps) {
|
|
const images = useGeneratedImages();
|
|
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
|
const prevLengthRef = useRef(0);
|
|
|
|
// Auto-scroll to latest image
|
|
useEffect(() => {
|
|
if (images.length > prevLengthRef.current) {
|
|
prevLengthRef.current = images.length;
|
|
}
|
|
}, [images]);
|
|
|
|
const visible = images.filter((img) => !dismissed.has(img.id));
|
|
const latest = visible.at(-1);
|
|
|
|
const dismiss = (id: string) => {
|
|
setDismissed((prev) => new Set([...prev, id]));
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'pointer-events-none fixed z-40 flex justify-center px-4',
|
|
chatOpen
|
|
? 'top-16 right-4 bottom-auto left-auto justify-end'
|
|
: 'inset-x-0 bottom-36 md:bottom-44'
|
|
)}
|
|
>
|
|
<div className={cn('pointer-events-auto', chatOpen ? 'w-48' : 'w-full max-w-sm')}>
|
|
<AnimatePresence mode="popLayout">
|
|
{latest && (
|
|
<ImageCard key={latest.id} image={latest} onDismiss={() => dismiss(latest.id)} />
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|