CI / ci (push) Canceled after 0s
Hero gallery: the slideshow on Home doesn’t open full screen. Only portfolio images do, as you asked. Lightbox: it has no pinch-zoom. Delete: removing an image still has no confirm step, same as before. New dependency: drag-and-drop uses @dnd-kit, which I added to package.json. Touch dragging: I couldn’t test it on a real phone. Mouse and keyboard dragging work. Health test: src/app/api/health/__tests__/route.test.ts passes, but it prints unhandled rejections. Its prisma mock has no notificationDelivery, so getQueueHealth throws. That comes from the earlier “Remaining features” commit and I left it alone. It’s a quick fix if you want it.
79 lines
3.2 KiB
TypeScript
79 lines
3.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { cn } from "@/lib/utils/cn";
|
|
|
|
export interface ImageGalleryProps {
|
|
images: { id: string; url: string; caption?: string | null }[];
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Home page hero — a real gallery with thumbnails (Phase 9), replacing
|
|
* the old single full-bleed logo banner. Matches the content width rather
|
|
* than bleeding edge-to-edge, per the brief. Plain click-to-swap + arrow
|
|
* buttons; no swipe-gesture library, since the thumbnail strip already
|
|
* covers "jump to any image" and arrows cover "next/previous" — a
|
|
* touch-drag carousel would be a reasonable future upgrade, not required
|
|
* for this to be a real gallery.
|
|
*/
|
|
export function ImageGallery({ images, className }: ImageGalleryProps) {
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
if (images.length === 0) return null;
|
|
|
|
const active = images[Math.min(activeIndex, images.length - 1)]!;
|
|
const goTo = (index: number) => setActiveIndex((index + images.length) % images.length);
|
|
|
|
return (
|
|
<div className={cn("w-full", className)}>
|
|
{/* A little taller on phones (3:2) so the cover fills more of the screen; 16:9 from tablet width up. */}
|
|
<div className="bg-stone relative aspect-[3/2] w-full overflow-hidden sm:aspect-[16/9]">
|
|
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
|
<img src={active.url} alt={active.caption ?? ""} className="h-full w-full object-cover" />
|
|
{images.length > 1 && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => goTo(activeIndex - 1)}
|
|
aria-label="Previous image"
|
|
className="bg-ink/60 hover:bg-ink/80 absolute top-1/2 left-3 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-sm text-white"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => goTo(activeIndex + 1)}
|
|
aria-label="Next image"
|
|
className="bg-ink/60 hover:bg-ink/80 absolute top-1/2 right-3 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-white"
|
|
>
|
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{images.length > 1 && (
|
|
<div className="px-1 mt-1 flex gap-1 overflow-x-auto">
|
|
{images.map((image, index) => (
|
|
<button
|
|
key={image.id}
|
|
type="button"
|
|
onClick={() => setActiveIndex(index)}
|
|
aria-label={`Show image ${index + 1}`}
|
|
aria-current={index === activeIndex}
|
|
className={cn(
|
|
"h-14 w-20 shrink-0 overflow-hidden border-2",
|
|
index === activeIndex ? "border-ink" : "border-transparent opacity-70",
|
|
)}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
|
<img src={image.url} alt="" className="h-full w-full object-cover" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|