| 1 | "use client"; |
| 2 | |
| 3 | import { useInfiniteQuery } from "@tanstack/react-query"; |
| 4 | import { Images } from "lucide-react"; |
| 5 | import Image from "next/image"; |
| 6 | import { useEffect } from "react"; |
| 7 | import { useInView } from "react-intersection-observer"; |
| 8 | |
| 9 | import { getUploadedImages } from "@/app/_actions/presentation/uploaded-image-actions"; |
| 10 | import { ScrollArea } from "@/components/ui/scroll-area"; |
| 11 | import { Skeleton } from "@/components/ui/skeleton"; |
| 12 | |
| 13 | const UPLOADED_IMAGES_PAGE_SIZE = 30; |
| 14 | |
| 15 | type UploadedImage = Awaited<ReturnType<typeof getUploadedImages>>[number]; |
| 16 | |
| 17 | interface UploadedImagesGridProps { |
| 18 | onImageSelect: (image: UploadedImage) => void; |
| 19 | } |
| 20 | |
| 21 | export function UploadedImagesGrid({ onImageSelect }: UploadedImagesGridProps) { |
| 22 | const { ref, inView } = useInView(); |
| 23 | |
| 24 | const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = |
| 25 | useInfiniteQuery({ |
| 26 | queryKey: ["presentation-uploaded-images"], |
| 27 | queryFn: ({ pageParam }) => |
| 28 | getUploadedImages({ |
| 29 | page: pageParam, |
| 30 | limit: UPLOADED_IMAGES_PAGE_SIZE, |
| 31 | }), |
| 32 | initialPageParam: 1, |
| 33 | getNextPageParam: (lastPage, allPages) => |
| 34 | lastPage.length === UPLOADED_IMAGES_PAGE_SIZE |
| 35 | ? allPages.length + 1 |
| 36 | : undefined, |
| 37 | }); |
| 38 | |
| 39 | useEffect(() => { |
| 40 | if (inView && hasNextPage && !isFetchingNextPage) { |
| 41 | void fetchNextPage(); |
| 42 | } |
| 43 | }, [fetchNextPage, hasNextPage, inView, isFetchingNextPage]); |
| 44 | |
| 45 | const uploadedImages = data?.pages.flat() ?? []; |
| 46 | const showEmptyState = !isLoading && uploadedImages.length === 0; |
| 47 | |
| 48 | return ( |
| 49 | <ScrollArea className="-mx-2 flex-1 px-2"> |
| 50 | <div className="grid grid-cols-3 gap-2 pb-4"> |
| 51 | {uploadedImages.map((image) => ( |
| 52 | <button |
| 53 | key={image.id} |
| 54 | type="button" |
| 55 | onClick={() => onImageSelect(image)} |
| 56 | 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" |
| 57 | title={image.name} |
| 58 | > |
| 59 | <Image |
| 60 | unoptimized |
| 61 | width={400} |
| 62 | height={300} |
| 63 | src={image.url} |
| 64 | alt={image.name} |
| 65 | className="h-full w-full object-cover transition-transform group-hover:scale-105" |
| 66 | loading="lazy" |
| 67 | /> |
| 68 | <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"> |
| 69 | {image.name} |
| 70 | </span> |
| 71 | </button> |
| 72 | ))} |
| 73 | |
| 74 | {(isLoading || isFetchingNextPage) && |
| 75 | Array.from({ length: 6 }, (_, index) => ( |
| 76 | <Skeleton key={index} className="aspect-square rounded-lg" /> |
| 77 | ))} |
| 78 | |
| 79 | <div ref={ref} className="col-span-3 h-4" /> |
| 80 | |
| 81 | {showEmptyState && ( |
| 82 | <div className="col-span-3 flex flex-col items-center justify-center gap-3 py-12 text-center text-muted-foreground"> |
| 83 | <Images className="size-8" /> |
| 84 | <p className="text-sm">No uploaded images found.</p> |
| 85 | </div> |
| 86 | )} |
| 87 | </div> |
| 88 | </ScrollArea> |
| 89 | ); |
| 90 | } |
| 91 |