| 1 | "use client"; |
| 2 | |
| 3 | import { useInfiniteQuery } from "@tanstack/react-query"; |
| 4 | import { Sparkles } from "lucide-react"; |
| 5 | import Image from "next/image"; |
| 6 | import { useEffect } from "react"; |
| 7 | import { useInView } from "react-intersection-observer"; |
| 8 | |
| 9 | import { |
| 10 | getUserImages, |
| 11 | type Image as GeneratedImage, |
| 12 | } from "@/app/_actions/apps/image-studio/fetch"; |
| 13 | import { ScrollArea } from "@/components/ui/scroll-area"; |
| 14 | import { Skeleton } from "@/components/ui/skeleton"; |
| 15 | |
| 16 | const GENERATED_IMAGES_PAGE_SIZE = 30; |
| 17 | |
| 18 | interface GeneratedImagesGridProps { |
| 19 | onImageSelect: (image: GeneratedImage) => void; |
| 20 | } |
| 21 | |
| 22 | export function GeneratedImagesGrid({ |
| 23 | onImageSelect, |
| 24 | }: GeneratedImagesGridProps) { |
| 25 | const { ref, inView } = useInView(); |
| 26 | |
| 27 | const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = |
| 28 | useInfiniteQuery({ |
| 29 | queryKey: ["user-generated-images"], |
| 30 | queryFn: ({ pageParam }) => |
| 31 | getUserImages({ |
| 32 | page: pageParam, |
| 33 | limit: GENERATED_IMAGES_PAGE_SIZE, |
| 34 | }), |
| 35 | initialPageParam: 1, |
| 36 | getNextPageParam: (lastPage, allPages) => |
| 37 | lastPage.length === GENERATED_IMAGES_PAGE_SIZE |
| 38 | ? allPages.length + 1 |
| 39 | : undefined, |
| 40 | }); |
| 41 | |
| 42 | useEffect(() => { |
| 43 | if (inView && hasNextPage && !isFetchingNextPage) { |
| 44 | void fetchNextPage(); |
| 45 | } |
| 46 | }, [fetchNextPage, hasNextPage, inView, isFetchingNextPage]); |
| 47 | |
| 48 | const generatedImages = data?.pages.flat() ?? []; |
| 49 | const showEmptyState = !isLoading && generatedImages.length === 0; |
| 50 | |
| 51 | return ( |
| 52 | <ScrollArea className="-mx-2 flex-1 px-2"> |
| 53 | <div className="grid grid-cols-3 gap-2 pb-4"> |
| 54 | {generatedImages.map((image) => ( |
| 55 | <button |
| 56 | key={image.id} |
| 57 | type="button" |
| 58 | onClick={() => onImageSelect(image)} |
| 59 | className="group relative aspect-square overflow-hidden rounded-lg border border-border bg-muted/30 text-left outline-none transition hover:border-primary/60 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/30" |
| 60 | title={image.prompt} |
| 61 | > |
| 62 | <Image |
| 63 | unoptimized |
| 64 | width={400} |
| 65 | height={300} |
| 66 | src={image.url} |
| 67 | alt={image.prompt} |
| 68 | className="h-full w-full object-cover transition-transform group-hover:scale-105" |
| 69 | loading="lazy" |
| 70 | /> |
| 71 | <span className="absolute inset-x-0 bottom-0 line-clamp-2 bg-black/60 px-2 py-1 text-[11px] leading-tight text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"> |
| 72 | {image.prompt} |
| 73 | </span> |
| 74 | </button> |
| 75 | ))} |
| 76 | |
| 77 | {(isLoading || isFetchingNextPage) && |
| 78 | Array.from({ length: 6 }, (_, index) => ( |
| 79 | <Skeleton key={index} className="aspect-square rounded-lg" /> |
| 80 | ))} |
| 81 | |
| 82 | <div ref={ref} className="col-span-3 h-4" /> |
| 83 | |
| 84 | {showEmptyState && ( |
| 85 | <div className="col-span-3 flex flex-col items-center justify-center gap-3 py-12 text-center text-muted-foreground"> |
| 86 | <Sparkles className="size-8" /> |
| 87 | <p className="text-sm">No generated images found.</p> |
| 88 | </div> |
| 89 | )} |
| 90 | </div> |
| 91 | </ScrollArea> |
| 92 | ); |
| 93 | } |
| 94 |