From bc5935701e6c16d72923425b4cc72ae48de8e1d5 Mon Sep 17 00:00:00 2001 From: Citali Date: Tue, 29 Jul 2025 12:32:04 +0200 Subject: [PATCH] Add lightbox --- src/actions/portfolio/getJustifiedImages.ts | 7 + src/app/page.tsx | 10 +- src/components/portfolio/ImageCard.tsx | 57 ++++--- src/components/portfolio/JustifiedGallery.tsx | 74 ++++++--- src/components/portfolio/Lightbox.tsx | 79 ++++++++++ src/components/ui/dialog.tsx | 143 ++++++++++++++++++ 6 files changed, 314 insertions(+), 56 deletions(-) create mode 100644 src/components/portfolio/Lightbox.tsx create mode 100644 src/components/ui/dialog.tsx diff --git a/src/actions/portfolio/getJustifiedImages.ts b/src/actions/portfolio/getJustifiedImages.ts index 8b49590..1eccc8a 100644 --- a/src/actions/portfolio/getJustifiedImages.ts +++ b/src/actions/portfolio/getJustifiedImages.ts @@ -56,6 +56,9 @@ export interface JustifiedInputImage { width: number; height: number; fileKey: string; + fullUrl: string; + fullWidth: number; + fullHeight: number; } interface Variant { @@ -199,6 +202,7 @@ export async function getJustifiedImages( for (const ctx of finalList) { const img = ctx.image; const variant = img.variants.find((v) => v.type === "resized"); + const full = img.variants.find((v) => v.type === "modified"); if (!variant || !variant.width || !variant.height) continue; @@ -212,6 +216,9 @@ export async function getJustifiedImages( width: variant.width, height: variant.height, url: variant.url ?? `/api/image/resized/${img.fileKey}.webp`, + fullUrl: full?.url ?? `/api/image/modified/${img.fileKey}.webp`, + fullWidth: full?.width ?? 0, + fullHeight: full?.height ?? 0, }); } diff --git a/src/app/page.tsx b/src/app/page.tsx index c58cac5..799b05c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,13 +13,19 @@ import Link from "next/link"; const sections = [ { title: "Art Portfolio", - href: "/portfolio", + href: "/portfolio/art", description: "My artwork gallery", icon: Palette, }, + { + title: "Artfight", + href: "/portfolio/artfight", + description: "Artfight pieces", + icon: Palette, + }, { title: "Miniatures", - href: "/miniatures", + href: "/portfolio/minis", description: "See my painted miniatures", icon: Brush, }, diff --git a/src/components/portfolio/ImageCard.tsx b/src/components/portfolio/ImageCard.tsx index 14c39ee..1655fb7 100644 --- a/src/components/portfolio/ImageCard.tsx +++ b/src/components/portfolio/ImageCard.tsx @@ -3,7 +3,6 @@ import type { Color, ImageColor, ImageVariant, PortfolioImage } from "@/generated/prisma"; import { cn } from "@/lib/utils"; import Image from "next/image"; -import Link from "next/link"; // ---------- Type Definitions ---------- @@ -57,40 +56,40 @@ export function ImageCard(props: ImageCardProps) { // console.log(props.image); return ( - + // +
-
- {altText} -
+ loading="lazy" + />
- +
+ // ); } diff --git a/src/components/portfolio/JustifiedGallery.tsx b/src/components/portfolio/JustifiedGallery.tsx index 41e844c..a846a29 100644 --- a/src/components/portfolio/JustifiedGallery.tsx +++ b/src/components/portfolio/JustifiedGallery.tsx @@ -6,6 +6,7 @@ import { } from "@/utils/justifyPortfolioImages"; import { useEffect, useRef, useState } from "react"; import { ImageCard } from "./ImageCard"; +import { Lightbox } from "./Lightbox"; interface Props { images: JustifiedImage[]; @@ -17,6 +18,7 @@ export function JustifiedGallery({ images, rowHeight = 200, gap = 4 }: Props) { const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(1200); const [rows, setRows] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(null); useEffect(() => { if (!containerRef.current) return; @@ -32,32 +34,54 @@ export function JustifiedGallery({ images, rowHeight = 200, gap = 4 }: Props) { setRows(newRows); }, [images, containerWidth, rowHeight, gap]); + const openModal = (index: number) => setSelectedIndex(index); + return ( -
- {rows.length === 0 && ( -

No images to display

+ <> +
+ {rows.length === 0 && ( +

No images to display

+ )} + {rows.map((row, i) => ( +
+ {row.map((img) => { + const index = images.findIndex((i) => i.id === img.id); + return ( +
openModal(index)} + className="cursor-zoom-in" + > + +
+ ) + })} +
+ ))} +
+ {selectedIndex !== null && ( + setSelectedIndex(null)} + hasPrev={selectedIndex > 0} + hasNext={selectedIndex < images.length - 1} + onPrev={() => setSelectedIndex((i) => (i !== null ? i - 1 : null))} + onNext={() => setSelectedIndex((i) => (i !== null ? i + 1 : null))} + /> )} - {rows.map((row, i) => ( -
- {row.map((img) => ( -
- -
- ))} -
- ))} -
+ ); } diff --git a/src/components/portfolio/Lightbox.tsx b/src/components/portfolio/Lightbox.tsx new file mode 100644 index 0000000..2a86dca --- /dev/null +++ b/src/components/portfolio/Lightbox.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { ChevronLeft, ChevronRight, X } from "lucide-react"; +import Image from "next/image"; +import { useEffect } from "react"; +import { createPortal } from "react-dom"; + +interface Props { + image: { + fullUrl: string; + altText?: string; + fullWidth: number; + fullHeight: number; + }; + onClose: () => void; + onPrev?: () => void; + onNext?: () => void; + hasPrev?: boolean; + hasNext?: boolean; +} + +export function Lightbox({ image, onClose, onPrev, onNext, hasPrev, hasNext }: Props) { + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + if (e.key === "ArrowLeft" && hasPrev) onPrev?.(); + if (e.key === "ArrowRight" && hasNext) onNext?.(); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [hasPrev, hasNext, onClose, onPrev, onNext]); + + return createPortal( +
+
e.stopPropagation()} + > + {image.altText + + {/* Close */} + + + {/* Prev/Next */} + {hasPrev && ( + + )} + {hasNext && ( + + )} +
+
, + document.body + ); +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..d9ccec9 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}