Files
podman/examples/livekit-gemini-hacker-starter/frontend/components/app/generated-image-panel.tsx
T
2026-06-28 07:20:54 +00:00

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>
);
}