// grid.tsx import Image from "next/image"; import React from "react"; import { cn } from "@gmana/utils"; import { type LucideIcon } from "lucide-react"; interface GridProps extends React.HTMLAttributes<HTMLUListElement> { columns?: 1 | 2 | 3 | 4 | 5 | 6; gap?: "small" | "medium" | "large"; responsive?: boolean; maintainAspectRatio?: boolean; } interface ImageData { src: string; alt: string; width?: number; height?: number; blurDataURL?: string; } type AspectRatioType = | "square" // 1:1 | "video" // 16:9 | "portrait" // 3:4 | "landscape" // 4:3 | "ultrawide" // 21:9 | "golden" // 1.618:1 | "custom" // custom ratio | "auto"; // natural ratio interface Caption { text: string; position?: "overlay" | "below" | "hover"; style?: "minimal" | "boxed" | "gradient"; align?: "left" | "center" | "right"; } const aspectRatioClasses: Record<AspectRatioType, string> = { square: "aspect-square", // 1:1 video: "aspect-video", // 16:9 portrait: "aspect-[3/4]", // 3:4 landscape: "aspect-[4/3]", // 4:3 ultrawide: "aspect-[21/9]", // 21:9 golden: "aspect-[1.618/1]", // Golden ratio custom: "", // Will be handled separately auto: "aspect-auto", // Natural aspect ratio }; const gapSizes = { small: 2, medium: 4, large: 6, }; const hoverEffects = { opacity: "hover:opacity-80", scale: "hover:scale-105", none: "", }; interface CaptionBadge { type: "icon" | "text" | "dot"; content: string; icon?: LucideIcon; color?: "primary" | "secondary" | "success" | "warning" | "error"; position?: "left" | "right"; } interface Caption { text: string; subtext?: string; position?: "overlay" | "below" | "hover"; style?: "minimal" | "boxed" | "gradient"; align?: "left" | "center" | "right"; maxLines?: number; badges?: CaptionBadge[]; icon?: LucideIcon; } interface GridItemProps extends Omit<React.HTMLAttributes<HTMLLIElement>, "children"> { aspectRatio?: AspectRatioType; customRatio?: string; hover?: "opacity" | "scale" | "none"; image?: ImageData; caption?: Caption | string; fill?: boolean; priority?: boolean; sizes?: string; quality?: number; objectFit?: "contain" | "cover" | "fill"; children?: React.ReactNode; } const badgeColors = { primary: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300", secondary: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300", success: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", warning: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", error: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", }; const captionStyles = { minimal: "text-sm text-gray-600 dark:text-gray-300", boxed: "text-sm p-3 bg-white/90 dark:bg-gray-800/90 shadow-sm backdrop-blur-sm", gradient: "text-sm text-white bg-gradient-to-t from-black/80 via-black/50 to-transparent p-4 w-full", }; const captionPositions = { overlay: "absolute bottom-0 left-0 right-0", below: "mt-2", hover: "absolute bottom-0 left-0 right-0 translate-y-full group-hover:translate-y-0 transition-transform duration-300", }; const captionAlignments = { left: "text-left", center: "text-center", right: "text-right", }; const CaptionBadgeComponent = ({ badge }: { badge: CaptionBadge }) => { const Icon = badge.icon; return ( <span className={cn("inline-flex items-center rounded-full px-2 py-1 text-xs font-medium", badgeColors[badge.color || "secondary"], "whitespace-nowrap")}> {badge.type === "icon" && Icon && <Icon className="mr-1 h-3 w-3" />} {badge.type === "dot" && <span className="mr-1 h-1.5 w-1.5 rounded-full bg-current" />} {badge.content} </span> ); }; function GridItem({ children, aspectRatio = "square", customRatio, hover = "opacity", image, caption, fill = true, priority = false, sizes = "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw", quality = 85, objectFit = "cover", className, ...props }: GridItemProps) { const getAspectRatioClass = () => { if (aspectRatio === "custom" && customRatio) { return `aspect-[${customRatio}]`; } return aspectRatioClasses[aspectRatio]; }; const captionConfig: Caption = typeof caption === "string" ? { text: caption, position: "below", style: "minimal", align: "left" } : { text: caption?.text ?? "", position: "below", style: "minimal", align: "left", ...caption, }; const CaptionIcon = captionConfig.icon; const renderCaption = () => ( <div className={cn( captionStyles[captionConfig.style ?? "gradient"], captionPositions[captionConfig.position ?? "overlay"], captionAlignments[captionConfig.align ?? "center"], "transition-all duration-300", )} > {/* Badges at the top of caption */} {captionConfig.badges && captionConfig.badges.length > 0 && ( <div className="mb-2 flex flex-wrap gap-1"> {captionConfig.badges.map((badge, index) => ( <CaptionBadgeComponent key={index} badge={badge} /> ))} </div> )} {/* Main caption content */} <div className="space-y-1"> <div className={cn("flex items-start gap-2", captionConfig.maxLines && `line-clamp-${captionConfig.maxLines}`)}> {CaptionIcon && <CaptionIcon className="mt-1 h-4 w-4 flex-shrink-0" />} <span>{captionConfig.text}</span> </div> {/* Subtext if provided */} {captionConfig.subtext && <p className={cn("text-xs opacity-80", captionConfig.maxLines && "line-clamp-2")}>{captionConfig.subtext}</p>} </div> </div> ); return ( <li {...props} className={cn(getAspectRatioClass(), "group relative overflow-hidden transition-all duration-300 ease-in-out", className)}> <div className="relative h-full w-full"> {image ? ( <Image src={image.src} alt={image.alt} fill={fill} className={cn(`object-${objectFit}`, hoverEffects[hover], hover === "scale" && "transition-transform duration-300")} sizes={sizes} quality={quality} priority={priority} placeholder={image.blurDataURL ? "blur" : undefined} blurDataURL={image.blurDataURL} {...(fill ? {} : { width: image.width, height: image.height, })} /> ) : ( children )} {caption && captionConfig.position !== "below" && renderCaption()} </div> {caption && captionConfig.position === "below" && renderCaption()} </li> ); } function Grid({ children, columns = 4, gap = "medium", responsive = true, maintainAspectRatio = true, className, ...props }: GridProps) { return ( <ul {...props} className={cn( "grid", `gap-${gapSizes[gap]}`, responsive ? { "grid-cols-1 sm:grid-cols-2": columns >= 2, "md:grid-cols-3": columns >= 3, "lg:grid-cols-4": columns >= 4, "xl:grid-cols-5": columns >= 5, "2xl:grid-cols-6": columns >= 6, } : { [`grid-cols-${columns}`]: true, }, maintainAspectRatio && "grid-flow-dense", className, )} > {children} </ul> ); } Grid.Item = GridItem; export type { AspectRatioType, GridItemProps, GridProps, ImageData }; export default Grid;
// page.tsx "use client"; import { Camera, DollarSign, Music, Video } from "lucide-react"; import { Grid, ImageData } from "@/components/grid"; export default function TestPage() { const images: ImageData[] = [ { src: "https://ik.imagekit.io/b3g0vu95i/1_5F0GRCOFh.jpeg?updatedAt=1729680589028", alt: "Description 1", // width: 800, // height: 600, }, { src: "https://ik.imagekit.io/b3g0vu95i/1_5F0GRCOFh?updatedAt=1728112174371", alt: "Description 1", // width: 800, // height: 600, }, { src: "https://ik.imagekit.io/b3g0vu95i/1_pO8WFtr5_?updatedAt=1729680298301", alt: "Description 2", // width: 800, // height: 600, }, { src: "https://ik.imagekit.io/b3g0vu95i/default-image.jpg?updatedAt=1726030777903", alt: "Description 2", // width: 800, // height: 600, }, { src: "https://ik.imagekit.io/b3g0vu95i/1.jpg?updatedAt=1726041267823", alt: "Description 2", // width: 800, // height: 600, }, { src: "https://ik.imagekit.io/b3g0vu95i/1_pO8WFtr5_?updatedAt=1728112101185", alt: "Description 2", // width: 800, // height: 600, }, { src: "https://ik.imagekit.io/b3g0vu95i/1_pO8WFtr5_?updatedAt=1728112101185", alt: "Description 2", // width: 800, // height: 600, }, { src: "https://ik.imagekit.io/b3g0vu95i/1_pO8WFtr5_?updatedAt=1728112101185", alt: "Description 2", // width: 800, // height: 600, }, { src: "https://ik.imagekit.io/b3g0vu95i/1_pO8WFtr5_?updatedAt=1728112101185", alt: "Description 2", // width: 800, // height: 600, }, ]; return ( <div className="container flex w-full flex-col items-center justify-center py-4"> <Grid columns={5} gap="medium" className="h-full w-full"> {/* {images.map((image, index) => ( <Grid.Item key={index} image={image} aspectRatio="auto" hover="scale" priority={index < 4} // Load first 4 images with priority caption={{ text: `Caption ${index + 1}`, position: "overlay", style: "boxed", align: "center", }} className="min-h-[300px]" /> ))} */} {images.map((image, index) => { return ( <Grid.Item key={index} aspectRatio="auto" image={{ src: "https://ik.imagekit.io/b3g0vu95i/1_pO8WFtr5_?updatedAt=1728112101185", alt: "Video thumbnail", }} hover="scale" caption={{ text: "Behind the Scenes: Making of the Documentary", subtext: "Director's cut with exclusive footage lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", maxLines: 2, position: "overlay", style: "gradient", icon: Video, badges: [ { type: "icon", content: "923.99", icon: DollarSign, color: "primary", }, { type: "text", content: "12:34", color: "secondary", }, { type: "icon", content: "Album", icon: Music, color: "primary", }, { type: "dot", content: "Featured", color: "success", }, ], }} className="min-h-[365px]" /> ); })} <Grid.Item aspectRatio="square" image={{ src: "https://ik.imagekit.io/b3g0vu95i/1_pO8WFtr5_?updatedAt=1728112101185", alt: "Album cover", }} caption={{ text: "New Album Release: Summer Vibes", subtext: "Including 12 original tracks and 3 bonus remixes", position: "overlay", style: "boxed", icon: Music, maxLines: 3, badges: [ { type: "icon", content: "Album", icon: Music, color: "primary", }, { type: "dot", content: "Featured", color: "success", }, ], }} /> <Grid.Item image={{ src: "https://ik.imagekit.io/b3g0vu95i/1_pO8WFtr5_?updatedAt=1728112101185", alt: "Mountain landscape", }} caption={{ text: "Mountain View", subtext: "Captured at sunset", icon: Camera, position: "overlay", style: "minimal", align: "right", badges: [ { type: "icon", content: "Featured", icon: Camera, color: "primary", }, ], }} hover="scale" aspectRatio="landscape" /> </Grid> </div> ); }
244 views