diff --git a/client/src/pages/project-detail/item-image-gallery-overlay.tsx b/client/src/pages/project-detail/item-image-gallery-overlay.tsx new file mode 100644 index 0000000..59ef980 --- /dev/null +++ b/client/src/pages/project-detail/item-image-gallery-overlay.tsx @@ -0,0 +1,151 @@ +import { useEffect, useRef } from 'react'; +import { ChevronLeft, ChevronRight, X } from 'lucide-react'; + +export interface GalleryImage { + id: string; + url: string; +} + +interface ItemImageGalleryOverlayProps { + isOpen: boolean; + itemName: string; + images: GalleryImage[]; + activeIndex: number; + isLoading: boolean; + error: string | null; + onClose: () => void; + onNext: () => void; + onPrevious: () => void; +} + +export function ItemImageGalleryOverlay({ + isOpen, + itemName, + images, + activeIndex, + isLoading, + error, + onClose, + onNext, + onPrevious +}: ItemImageGalleryOverlayProps) { + const closeButtonRef = useRef(null); + + useEffect(() => { + if (!isOpen) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + + if (isLoading || images.length <= 1) { + return; + } + + if (event.key === 'ArrowRight') { + event.preventDefault(); + onNext(); + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + onPrevious(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose, onNext, onPrevious, images.length, isLoading]); + + useEffect(() => { + if (isOpen) { + closeButtonRef.current?.focus(); + } + }, [isOpen]); + + if (!isOpen) { + return null; + } + + const activeImage = images[activeIndex] ?? null; + + return ( +
+
event.stopPropagation()} + > +
+
+

{itemName}

+

+ {images.length > 0 ? `Image ${activeIndex + 1} of ${images.length}` : 'No images available'} +

+
+ +
+ +
+ {isLoading ? ( +

Loading images…

+ ) : error ? ( +

{error}

+ ) : activeImage ? ( + <> + {`Image + {images.length > 1 ? ( + <> + + + + ) : null} + + ) : ( +

+ No images have been added for this item yet. +

+ )} +
+
+
+ ); +} diff --git a/client/src/pages/project-detail/item-thumbnail.tsx b/client/src/pages/project-detail/item-thumbnail.tsx index 706f9f4..b91f62c 100644 --- a/client/src/pages/project-detail/item-thumbnail.tsx +++ b/client/src/pages/project-detail/item-thumbnail.tsx @@ -4,6 +4,7 @@ interface ItemThumbnailProps { url: string | null; alt: string; size?: 'sm' | 'md'; + onClick?: () => void; } const sizeClassMap: Record['size'], string> = { @@ -11,28 +12,40 @@ const sizeClassMap: Record['size'], string> = { md: 'h-20 w-20' }; -export function ItemThumbnail({ url, alt, size = 'sm' }: ItemThumbnailProps) { +export function ItemThumbnail({ url, alt, size = 'sm', onClick }: ItemThumbnailProps) { const [hasError, setHasError] = useState(false); const hasImage = !!url && !hasError; const sizeClasses = sizeClassMap[size]; + const containerClasses = `flex-shrink-0 overflow-hidden rounded-md border border-slate-200 bg-slate-100 ${sizeClasses} ${ + onClick ? 'cursor-pointer transition hover:border-blue-200 hover:shadow focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white' : '' + }`; - return ( -
- {hasImage ? ( - {alt} setHasError(true)} - /> - ) : ( -
- No Image -
- )} + const content = hasImage ? ( + {alt} setHasError(true)} + /> + ) : ( +
+ No Image
); + + if (onClick) { + return ( + + ); + } + + return
{content}
; } diff --git a/client/src/pages/project-detail/project-detail-page.tsx b/client/src/pages/project-detail/project-detail-page.tsx index 43462f9..5e52954 100644 --- a/client/src/pages/project-detail/project-detail-page.tsx +++ b/client/src/pages/project-detail/project-detail-page.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import type { Item } from '@shared/models/item'; import { useQueryClient } from '@tanstack/react-query'; @@ -22,6 +22,7 @@ import { ProjectHeader } from './project-header'; import { ProjectDescriptionSection } from './project-description-section'; import { TrackedAttributesSection } from './tracked-attributes-section'; import { ProjectItemsSection } from './project-items-section'; +import { ItemImageGalleryOverlay, type GalleryImage } from './item-image-gallery-overlay'; export function ProjectDetailPage() { const { projectId } = useParams<{ projectId: string }>(); @@ -47,6 +48,16 @@ export function ProjectDetailPage() { const [isLoadingItemDetails, setIsLoadingItemDetails] = useState(false); const [editItemError, setEditItemError] = useState(null); const [isSavingItem, setIsSavingItem] = useState(false); + const [isGalleryOpen, setIsGalleryOpen] = useState(false); + const [galleryItemName, setGalleryItemName] = useState(''); + const [galleryImages, setGalleryImages] = useState([]); + const [galleryActiveIndex, setGalleryActiveIndex] = useState(0); + const [isGalleryLoading, setIsGalleryLoading] = useState(false); + const [galleryError, setGalleryError] = useState(null); + const galleryCacheRef = useRef( + new Map() + ); + const galleryRequestIdRef = useRef(0); const project = projectQuery.data; const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]); @@ -79,6 +90,126 @@ export function ProjectDetailPage() { return Array.from(attributeSet).sort((a, b) => a.localeCompare(b)); }, [projectAttributes, items]); + const handleViewItemImages = useCallback( + async (item: Item) => { + const displayName = item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model; + + galleryRequestIdRef.current += 1; + const requestId = galleryRequestIdRef.current; + + const cached = galleryCacheRef.current.get(item.id); + const fallbackImages = + item.images && item.images.length + ? item.images.map((image) => ({ id: image.id, url: image.url })) + : []; + + let initialImages = cached?.images ?? fallbackImages; + + if (!initialImages.length && item.defaultImageUrl) { + initialImages = [ + { + id: item.defaultImageId ?? 'default-image', + url: item.defaultImageUrl + } + ]; + } + + const initialDefaultId = cached?.defaultImageId ?? item.defaultImageId ?? null; + + setIsGalleryOpen(true); + setGalleryItemName(displayName); + setGalleryError(null); + setGalleryImages(initialImages); + setIsGalleryLoading(!cached && initialImages.length === 0); + + if (initialImages.length) { + if (initialDefaultId) { + const defaultIndex = initialImages.findIndex((image) => image.id === initialDefaultId); + setGalleryActiveIndex(defaultIndex >= 0 ? defaultIndex : 0); + } else { + setGalleryActiveIndex(0); + } + } else { + setGalleryActiveIndex(0); + } + + if (cached) { + return; + } + + try { + const fullItem = await fetchItem(item.id); + if (galleryRequestIdRef.current !== requestId) { + return; + } + + const normalizedImages = + fullItem.images?.map((image) => ({ + id: image.id, + url: image.url + })) ?? []; + const defaultId = fullItem.defaultImageId ?? null; + + galleryCacheRef.current.set(item.id, { + images: normalizedImages, + defaultImageId: defaultId + }); + + setGalleryImages(normalizedImages); + + if (normalizedImages.length) { + const resolvedIndex = defaultId + ? normalizedImages.findIndex((image) => image.id === defaultId) + : -1; + setGalleryActiveIndex(resolvedIndex >= 0 ? resolvedIndex : 0); + } else { + setGalleryActiveIndex(0); + } + setGalleryError(null); + } catch (error) { + if (galleryRequestIdRef.current !== requestId) { + return; + } + + const message = + error instanceof Error && error.message.trim().length + ? error.message.trim() + : 'Failed to load item images.'; + setGalleryError(message); + } finally { + if (galleryRequestIdRef.current === requestId) { + setIsGalleryLoading(false); + } + } + }, + [] + ); + + const handleCloseGallery = useCallback(() => { + galleryRequestIdRef.current += 1; + setIsGalleryOpen(false); + setIsGalleryLoading(false); + setGalleryError(null); + }, []); + + const handleGalleryNext = useCallback(() => { + setGalleryActiveIndex((current) => { + if (galleryImages.length <= 1) { + return current; + } + return (current + 1) % galleryImages.length; + }); + }, [galleryImages]); + + const handleGalleryPrevious = useCallback(() => { + setGalleryActiveIndex((current) => { + if (galleryImages.length <= 1) { + return current; + } + return (current - 1 + galleryImages.length) % galleryImages.length; + }); + }, [galleryImages]); + const openItemModal = (mode: 'url' | 'manual' | 'edit', initialUrl: string | null) => { setItemModalMode(mode); setModalInitialUrl(initialUrl); @@ -467,6 +598,7 @@ export function ProjectDetailPage() { isImportingFromUrl={isImportingItem} onEditItem={handleEditItem} projectId={projectId} + onViewItemImages={handleViewItemImages} /> + +
); } diff --git a/client/src/pages/project-detail/project-items-desktop-table.tsx b/client/src/pages/project-detail/project-items-desktop-table.tsx index 19ef675..b7fe815 100644 --- a/client/src/pages/project-detail/project-items-desktop-table.tsx +++ b/client/src/pages/project-detail/project-items-desktop-table.tsx @@ -12,6 +12,7 @@ interface ProjectItemsDesktopTableProps { onTogglePrices: (itemId: string) => void; onEditItem: (item: Item) => void; projectId: string | undefined; + onViewItemImages: (item: Item) => void; } export function ProjectItemsDesktopTable({ @@ -20,7 +21,8 @@ export function ProjectItemsDesktopTable({ expandedItemId, onTogglePrices, onEditItem, - projectId + projectId, + onViewItemImages }: ProjectItemsDesktopTableProps) { return (
@@ -59,38 +61,43 @@ export function ProjectItemsDesktopTable({
- + onViewItemImages(item)} + />
- - {item.sourceUrl ? ( - onEditItem(item)} + className="group inline-flex items-center gap-2 rounded-md text-left text-slate-900 transition hover:text-blue-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white" + title="Edit item details" > - - Open source link - - ) : null} -
+ + + {displayName} + + + {item.sourceUrl ? ( + + + Open source link + + ) : null} +
{item.note ? ( {item.note} ) : null} diff --git a/client/src/pages/project-detail/project-items-mobile-list.tsx b/client/src/pages/project-detail/project-items-mobile-list.tsx index 8a8473a..5f84da0 100644 --- a/client/src/pages/project-detail/project-items-mobile-list.tsx +++ b/client/src/pages/project-detail/project-items-mobile-list.tsx @@ -11,6 +11,7 @@ interface ProjectItemsMobileListProps { onTogglePrices: (itemId: string) => void; onEditItem: (item: Item) => void; projectId: string | undefined; + onViewItemImages: (item: Item) => void; } export function ProjectItemsMobileList({ @@ -19,7 +20,8 @@ export function ProjectItemsMobileList({ expandedItemId, onTogglePrices, onEditItem, - projectId + projectId, + onViewItemImages }: ProjectItemsMobileListProps) { return (
@@ -33,7 +35,12 @@ export function ProjectItemsMobileList({ return (
- + onViewItemImages(item)} + />