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

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