"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckIcon, ImageOffIcon, Loader2Icon, SparklesIcon, XIcon, } from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import type { GalleryPhoto } from "@/types/admin/gallery"; interface GalleryGridProps { initialPhotos?: GalleryPhoto[]; } type PickedFilter = "all" | "picked" | "unpicked"; type ColorFilter = | "all" | "black" | "blue" | "green" | "light" | "orange" | "purple" | "red" | "yellow"; const PER_PAGE = 24; const COLUMNS_COUNT = 4; const colorFilters: Array<{ label: string; value: ColorFilter }> = [ { label: "All colors", value: "all" }, { label: "Light", value: "light" }, { label: "Black", value: "black" }, { label: "Red", value: "red" }, { label: "Orange", value: "orange" }, { label: "Yellow", value: "yellow" }, { label: "Green", value: "green" }, { label: "Blue", value: "blue" }, { label: "Purple", value: "purple" }, ]; function getImageUrl(photo: GalleryPhoto): string { const urls = photo.urls as Record | undefined; return urls?.small ?? urls?.regular ?? urls?.thumb ?? urls?.raw ?? ""; } function getFullImageUrl(photo: GalleryPhoto): string { const urls = photo.urls as Record | undefined; return photo.oss_url ?? urls?.full ?? urls?.regular ?? urls?.raw ?? ""; } function getUserName(photo: GalleryPhoto): string { const user = photo.user_info as Record | undefined; return (user?.name as string) || (user?.username as string) || "Unknown"; } function getPhotoAspectRatio(photo: GalleryPhoto): number { if (photo.width && photo.height && photo.height > 0) { return photo.width / photo.height; } return 1.5; } function distributeToColumns( photos: GalleryPhoto[], columnCount: number, ): GalleryPhoto[][] { const columns: GalleryPhoto[][] = Array.from( { length: columnCount }, () => [], ); const columnHeights = Array(columnCount).fill(0); for (const photo of photos) { const shortestColIndex = columnHeights.indexOf( Math.min(...columnHeights), ); columns[shortestColIndex].push(photo); columnHeights[shortestColIndex] += 1 / getPhotoAspectRatio(photo); } return columns; } export function GalleryGrid({ initialPhotos = [] }: GalleryGridProps) { const [columns, setColumns] = useState([]); const [allPhotos, setAllPhotos] = useState(initialPhotos); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(null); const [selectedPhoto, setSelectedPhoto] = useState( null, ); const [lightboxVisible, setLightboxVisible] = useState(false); const [loadedImages, setLoadedImages] = useState>(new Set()); const [pickedFilter, setPickedFilter] = useState("all"); const [colorFilter, setColorFilter] = useState("all"); const [pickingIds, setPickingIds] = useState>(new Set()); const observerRef = useRef(null); const sentinelRef = useRef(null); const loadingRef = useRef(loading); const hasMoreRef = useRef(hasMore); useEffect(() => { loadingRef.current = loading; }, [loading]); useEffect(() => { hasMoreRef.current = hasMore; }, [hasMore]); useEffect(() => { setColumns(distributeToColumns(allPhotos, COLUMNS_COUNT)); }, [allPhotos]); const fetchPhotos = useCallback( async (targetPage: number, replace = false) => { if (loadingRef.current || (!replace && !hasMoreRef.current)) { return; } setLoading(true); setError(null); try { const params = new URLSearchParams({ page: String(targetPage), per_page: String(PER_PAGE), }); if (pickedFilter === "picked") { params.set("picked", "true"); } else if (pickedFilter === "unpicked") { params.set("picked", "false"); } if (colorFilter !== "all") { params.set("color_family", colorFilter); } const res = await fetch( `/api/admin/gallery-photos?${params.toString()}`, ); const json = await res.json(); if (!json.ok) { throw new Error(json.error || "Failed to fetch photos."); } const newPhotos: GalleryPhoto[] = json.data; setAllPhotos((prev) => replace || targetPage === 1 ? newPhotos : [...prev, ...newPhotos], ); setHasMore(json.pagination.hasMore); setPage(targetPage); } catch (err) { setError(err instanceof Error ? err.message : "Unknown error."); } finally { setLoading(false); } }, [colorFilter, pickedFilter], ); useEffect(() => { hasMoreRef.current = true; setHasMore(true); setPage(1); setAllPhotos([]); setLoadedImages(new Set()); void fetchPhotos(1, true); }, [fetchPhotos]); useEffect(() => { observerRef.current?.disconnect(); observerRef.current = new IntersectionObserver( (entries) => { if ( entries[0]?.isIntersecting && hasMoreRef.current && !loadingRef.current ) { void fetchPhotos(page + 1); } }, { rootMargin: "200px" }, ); if (sentinelRef.current) { observerRef.current.observe(sentinelRef.current); } return () => { observerRef.current?.disconnect(); }; }, [fetchPhotos, page]); const replacePhoto = useCallback( (updated: GalleryPhoto) => { setAllPhotos((prev) => { const shouldRemoveFromCurrentFilter = (pickedFilter === "picked" && !updated.is_picked) || (pickedFilter === "unpicked" && updated.is_picked); if (shouldRemoveFromCurrentFilter) { return prev.filter( (photo) => photo.unsplash_id !== updated.unsplash_id, ); } return prev.map((photo) => photo.unsplash_id === updated.unsplash_id ? updated : photo, ); }); setSelectedPhoto((photo) => photo?.unsplash_id === updated.unsplash_id ? updated : photo, ); }, [pickedFilter], ); const togglePicked = async (photo: GalleryPhoto) => { const nextPicked = !photo.is_picked; const toastId = toast.loading( nextPicked ? "Picking photo..." : "Unpicking photo...", ); setPickingIds((prev) => new Set(prev).add(photo.unsplash_id)); setError(null); try { const res = await fetch("/api/admin/gallery-photos", { method: "PATCH", headers: { "content-type": "application/json", }, body: JSON.stringify({ unsplashId: photo.unsplash_id, isPicked: nextPicked, }), }); const json = await res.json().catch(() => null); if (!res.ok || !json?.ok) { throw new Error( json?.error || `Failed to update pick status (${res.status}).`, ); } replacePhoto(json.data); toast.success(nextPicked ? "Photo picked" : "Photo unpicked", { id: toastId, description: json.data.oss_sync_error || undefined, }); } catch (err) { const message = err instanceof Error ? err.message : "Unknown pick error."; setError(message); toast.error("Pick request failed", { id: toastId, description: message, }); } finally { setPickingIds((prev) => { const next = new Set(prev); next.delete(photo.unsplash_id); return next; }); } }; const handlePhotoClick = (photo: GalleryPhoto) => { setSelectedPhoto(photo); requestAnimationFrame(() => { setLightboxVisible(true); }); }; const handleCloseModal = () => { setLightboxVisible(false); setTimeout(() => { setSelectedPhoto(null); }, 250); }; const handleImageLoad = (unsplashId: string) => { setLoadedImages((prev) => new Set(prev).add(unsplashId)); }; const totalPickedOnPage = useMemo( () => allPhotos.filter((photo) => photo.is_picked).length, [allPhotos], ); const renderPhotoCard = ( photo: GalleryPhoto, colIndex: number, itemIndex: number, ) => { const isLoaded = loadedImages.has(photo.unsplash_id); const isPicking = pickingIds.has(photo.unsplash_id); const staggerDelay = ((colIndex * 3 + itemIndex) % 12) * 60; return (
handlePhotoClick(photo)} >
{/* eslint-disable-next-line @next/next/no-img-element */} { handleImageLoad(photo.unsplash_id)} className={`ease-out-quart h-auto w-full object-cover transition-all duration-250 group-hover:scale-[1.03] ${ isLoaded ? "opacity-100" : "opacity-0" }`} style={{ transitionProperty: "opacity, transform", }} />
{photo.is_picked && ( Picked )} {photo.oss_url && ( OSS )}

{getUserName(photo)}

{photo.likes ? ( {photo.likes} likes ) : null} {photo.color_family ? ( {photo.color_family} ) : null}
); }; return (
{allPhotos.length} loaded / {totalPickedOnPage} picked
{columns.map((columnPhotos, colIndex) => (
{columnPhotos.map((photo, itemIndex) => renderPhotoCard(photo, colIndex, itemIndex), )}
))}
{loading && (
Loading more...
)} {error && (

{error}

)} {!hasMore && allPhotos.length > 0 && (

No more photos

)} {!loading && allPhotos.length === 0 && !error && (

No photos found

Run the sync script to populate the gallery.

)}
{selectedPhoto && (
event.stopPropagation()} > {/* eslint-disable-next-line @next/next/no-img-element */} {

{getUserName(selectedPhoto)}

{selectedPhoto.is_picked && ( Picked )}
{selectedPhoto.description && (

{selectedPhoto.description}

)} {selectedPhoto.oss_sync_error && (

{selectedPhoto.oss_sync_error}

)}
)}
); }