-
-
Save shricodev/48333967716098daac1e0b1809499f3b to your computer and use it in GitHub Desktop.
Image Gallery Implementation with Infinite Scrolling (Generated by Claude 3.7 Sonnet AI Model). This gist is for one of my blog post comparison of AI Models.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client"; | |
| import { useState, useEffect, useRef, useCallback } from "react"; | |
| import { useInfiniteQuery } from "@tanstack/react-query"; | |
| import { debounce } from "lodash"; | |
| import Image from "next/image"; | |
| import { motion } from "framer-motion"; | |
| // Define image interface | |
| interface ImageItem { | |
| id: string; | |
| url: string; | |
| alt: string; | |
| width: number; | |
| height: number; | |
| tags: string[]; | |
| photographer: string; | |
| color: string; | |
| } | |
| // Mock API for fetching images | |
| const fetchImages = async ({ | |
| pageParam = 0, | |
| searchTerm = "", | |
| }): Promise<{ | |
| images: ImageItem[]; | |
| nextPage: number | null; | |
| }> => { | |
| // Simulating API call delay | |
| await new Promise((resolve) => setTimeout(resolve, 800)); | |
| // Generate some mock images with varying aspect ratios | |
| const allImages: ImageItem[] = Array(30) | |
| .fill(0) | |
| .map((_, index) => { | |
| const id = (pageParam * 30 + index + 1).toString(); | |
| // More variation in aspect ratios to create a true masonry effect | |
| const aspectOptions = [ | |
| { width: 1200, height: 800 }, // landscape 3:2 | |
| { width: 800, height: 1200 }, // portrait 2:3 | |
| { width: 1000, height: 1000 }, // square 1:1 | |
| { width: 900, height: 1200 }, // portrait 3:4 | |
| { width: 1200, height: 900 }, // landscape 4:3 | |
| { width: 1200, height: 600 }, // wide landscape 2:1 | |
| { width: 600, height: 1200 }, // tall portrait 1:2 | |
| ]; | |
| const { width, height } = | |
| aspectOptions[Math.floor(Math.random() * aspectOptions.length)]; | |
| const tags = [ | |
| "nature", | |
| "architecture", | |
| "people", | |
| "technology", | |
| "travel", | |
| "food", | |
| "animals", | |
| "city", | |
| "night", | |
| "abstract", | |
| ] | |
| .sort(() => 0.5 - Math.random()) | |
| .slice(0, Math.floor(Math.random() * 4) + 1); | |
| return { | |
| id, | |
| url: `https://picsum.photos/500/700?sig=${pageParam * 100 + index}`, | |
| alt: `Random image ${id}`, | |
| width, | |
| height, | |
| tags, | |
| photographer: [ | |
| "John Doe", | |
| "Jane Smith", | |
| "Alex Johnson", | |
| "Sam Wilson", | |
| "Taylor Swift", | |
| ][Math.floor(Math.random() * 5)], | |
| color: ["#F5F5F5", "#E8E8E8", "#D9D9D9", "#CCCCCC", "#BFBFBF"][ | |
| Math.floor(Math.random() * 5) | |
| ], | |
| }; | |
| }); | |
| // Filter images if a search term is provided | |
| const filteredImages = searchTerm | |
| ? allImages.filter( | |
| (img) => | |
| img.tags.some((tag) => | |
| tag.toLowerCase().includes(searchTerm.toLowerCase()), | |
| ) || | |
| img.photographer.toLowerCase().includes(searchTerm.toLowerCase()), | |
| ) | |
| : allImages; | |
| return { | |
| images: filteredImages, | |
| nextPage: pageParam < 4 ? pageParam + 1 : null, // Limit to 5 pages for demo | |
| }; | |
| }; | |
| export default function Gallery() { | |
| const [searchTerm, setSearchTerm] = useState(""); | |
| const [debouncedSearch, setDebouncedSearch] = useState(""); | |
| const observer = useRef<IntersectionObserver | null>(null); | |
| const lastImageRef = useRef<HTMLDivElement | null>(null); | |
| const [columns, setColumns] = useState(3); | |
| // Update columns based on screen size | |
| useEffect(() => { | |
| const updateColumns = () => { | |
| if (window.innerWidth < 640) { | |
| setColumns(1); | |
| } else if (window.innerWidth < 768) { | |
| setColumns(2); | |
| } else if (window.innerWidth < 1024) { | |
| setColumns(3); | |
| } else { | |
| setColumns(4); | |
| } | |
| }; | |
| updateColumns(); | |
| window.addEventListener("resize", updateColumns); | |
| return () => window.removeEventListener("resize", updateColumns); | |
| }, []); | |
| // Debounce search input | |
| const handleSearch = debounce((value: string) => { | |
| setDebouncedSearch(value); | |
| }, 500); | |
| const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } = | |
| useInfiniteQuery({ | |
| queryKey: ["images", debouncedSearch], | |
| queryFn: ({ pageParam }) => | |
| fetchImages({ pageParam, searchTerm: debouncedSearch }), | |
| getNextPageParam: (lastPage) => lastPage.nextPage, | |
| initialPageParam: 0, | |
| }); | |
| // Setup intersection observer for infinite scrolling | |
| const lastImageCallback = useCallback( | |
| (node: HTMLDivElement | null) => { | |
| if (isFetchingNextPage) return; | |
| if (observer.current) observer.current.disconnect(); | |
| observer.current = new IntersectionObserver( | |
| (entries) => { | |
| if (entries[0].isIntersecting && hasNextPage) { | |
| fetchNextPage(); | |
| } | |
| }, | |
| { | |
| rootMargin: "300px", | |
| }, | |
| ); | |
| if (node) { | |
| lastImageRef.current = node; | |
| observer.current.observe(node); | |
| } | |
| }, | |
| [isFetchingNextPage, fetchNextPage, hasNextPage], | |
| ); | |
| // Update search term | |
| const onSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| setSearchTerm(e.target.value); | |
| handleSearch(e.target.value); | |
| }; | |
| // Flatten the pages data | |
| const allImages = data ? data.pages.flatMap((page) => page.images) : []; | |
| // Distribute images into columns for masonry layout | |
| const distributeImagesIntoColumns = ( | |
| images: ImageItem[], | |
| columnCount: number, | |
| ) => { | |
| const columns: ImageItem[][] = Array.from( | |
| { length: columnCount }, | |
| () => [], | |
| ); | |
| // Calculate initial heights for each column | |
| const columnHeights = Array(columnCount).fill(0); | |
| // Place each image in the shortest column | |
| images.forEach((image) => { | |
| const shortestColumnIndex = columnHeights.indexOf( | |
| Math.min(...columnHeights), | |
| ); | |
| columns[shortestColumnIndex].push(image); | |
| // Update the height of the column | |
| const aspectRatio = image.width / image.height; | |
| const estimatedHeight = 1 / aspectRatio; | |
| columnHeights[shortestColumnIndex] += estimatedHeight; | |
| }); | |
| return columns; | |
| }; | |
| const imageColumns = distributeImagesIntoColumns(allImages, columns); | |
| return ( | |
| <div className="min-h-screen bg-gray-50"> | |
| {/* Header */} | |
| <header className="sticky top-0 z-50 bg-white shadow-md"> | |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> | |
| <div className="flex flex-col md:flex-row md:items-center md:justify-between"> | |
| <div className="text-2xl md:text-3xl font-bold text-gray-900 mb-4 md:mb-0"> | |
| <span className="text-indigo-600">Pixel</span>Gallery | |
| </div> | |
| <div className="relative w-full md:w-1/2 lg:w-1/3"> | |
| <input | |
| type="text" | |
| placeholder="Search for photos" | |
| className="w-full px-4 py-3 text-black rounded-lg bg-gray-100 border-transparent focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-200 transition-colors" | |
| value={searchTerm} | |
| onChange={onSearchChange} | |
| /> | |
| <div className="absolute right-3 top-3 text-gray-400"> | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| className="h-6 w-6" | |
| fill="none" | |
| viewBox="0 0 24 24" | |
| stroke="currentColor" | |
| > | |
| <path | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| strokeWidth={2} | |
| d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" | |
| /> | |
| </svg> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | |
| {/* Status indicators */} | |
| {status === "pending" && ( | |
| <div className="flex justify-center items-center h-64"> | |
| <div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-indigo-500"></div> | |
| </div> | |
| )} | |
| {status === "error" && ( | |
| <div className="flex justify-center items-center h-64"> | |
| <div className="text-red-500 text-xl">Error loading images</div> | |
| </div> | |
| )} | |
| {status === "success" && allImages.length === 0 && ( | |
| <div className="flex flex-col justify-center items-center h-64"> | |
| <div className="text-gray-400 text-xl mb-4">No images found</div> | |
| <p className="text-gray-500">Try a different search term</p> | |
| </div> | |
| )} | |
| {/* Gallery Header */} | |
| {status === "success" && allImages.length > 0 && ( | |
| <> | |
| <div className="mb-8"> | |
| <h2 className="text-xl font-semibold text-gray-700"> | |
| {debouncedSearch | |
| ? `Results for "${debouncedSearch}"` | |
| : "Popular Photos"} | |
| </h2> | |
| <p className="text-gray-500">{allImages.length} photos</p> | |
| </div> | |
| {/* Masonry Grid */} | |
| <div className="flex gap-3 w-full"> | |
| {imageColumns.map((column, columnIndex) => ( | |
| <div key={columnIndex} className="flex flex-col gap-3 flex-1"> | |
| {column.map((image, imageIndex) => { | |
| // Set the reference on the last few images for infinite scrolling | |
| const isNearEnd = | |
| columnIndex === imageColumns.length - 1 && | |
| imageIndex >= column.length - 2; | |
| return ( | |
| <motion.div | |
| key={image.id} | |
| ref={isNearEnd ? lastImageCallback : null} | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.3 }} | |
| className="relative group rounded-lg overflow-hidden shadow-sm hover:shadow-xl transition-shadow duration-300" | |
| > | |
| <div | |
| className="relative w-full" | |
| style={{ | |
| paddingBottom: `${(image.height / image.width) * 100}%`, | |
| }} | |
| > | |
| <Image | |
| src={image.url} | |
| alt={image.alt} | |
| fill | |
| sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw" | |
| className="object-cover" | |
| placeholder="blur" | |
| blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAEDQIEXlJTlwAAAABJRU5ErkJggg==" | |
| /> | |
| {/* Image overlay with info */} | |
| <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 p-4 flex flex-col justify-end"> | |
| <h3 className="text-white font-medium"> | |
| {image.photographer} | |
| </h3> | |
| <div className="flex flex-wrap gap-1 mt-2"> | |
| {image.tags.map((tag) => ( | |
| <span | |
| key={tag} | |
| className="text-xs bg-black/30 text-white px-2 py-1 rounded-full backdrop-blur-sm" | |
| > | |
| {tag} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </motion.div> | |
| ); | |
| })} | |
| </div> | |
| ))} | |
| </div> | |
| </> | |
| )} | |
| {/* Loading more indicator */} | |
| {isFetchingNextPage && ( | |
| <div className="flex justify-center items-center h-24 mt-8"> | |
| <div className="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div> | |
| </div> | |
| )} | |
| {/* End of results */} | |
| {!hasNextPage && status === "success" && allImages.length > 0 && ( | |
| <div className="text-center text-gray-500 mt-12 pb-8"> | |
| <p>You've reached the end of results</p> | |
| </div> | |
| )} | |
| </main> | |
| {/* Footer */} | |
| <footer className="bg-gray-900 text-white py-8"> | |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | |
| <div className="flex flex-col md:flex-row justify-between items-center"> | |
| <div className="mb-4 md:mb-0"> | |
| <div className="text-xl font-bold"> | |
| <span className="text-indigo-400">Pixel</span>Gallery | |
| </div> | |
| <p className="text-gray-400 text-sm"> | |
| Beautiful, free images for your next project | |
| </p> | |
| </div> | |
| <div> | |
| <p className="text-gray-400 text-sm"> | |
| © {new Date().getFullYear()} PixelGallery. All rights reserved. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </footer> | |
| </div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment