feat: add item gallery overlay

This commit is contained in:
Jurgis Sakalauskas
2025-10-17 19:40:33 +03:00
parent d5cac734aa
commit 4701eb974d
6 changed files with 378 additions and 52 deletions
@@ -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<HTMLButtonElement | null>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/80 px-4 py-8"
role="dialog"
aria-modal="true"
aria-label={`Image gallery for ${itemName}`}
onClick={onClose}
>
<div
className="relative flex w-full max-w-4xl flex-col gap-4 rounded-2xl bg-white p-6 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">{itemName}</h2>
<p className="text-sm text-slate-500">
{images.length > 0 ? `Image ${activeIndex + 1} of ${images.length}` : 'No images available'}
</p>
</div>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
title="Close gallery"
>
<X aria-hidden className="h-5 w-5" />
<span className="sr-only">Close gallery</span>
</button>
</div>
<div className="relative flex min-h-[320px] items-center justify-center overflow-hidden rounded-xl bg-slate-100">
{isLoading ? (
<p className="text-sm text-slate-500">Loading images</p>
) : error ? (
<p className="px-4 text-center text-sm font-medium text-red-600">{error}</p>
) : activeImage ? (
<>
<img
key={activeImage.id}
src={activeImage.url}
alt={`Image ${activeIndex + 1} of ${itemName}`}
className="max-h-[70vh] w-full object-contain"
/>
{images.length > 1 ? (
<>
<button
type="button"
onClick={onPrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/90 p-2 text-slate-700 shadow transition hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
title="Previous image"
>
<ChevronLeft aria-hidden className="h-5 w-5" />
<span className="sr-only">Previous image</span>
</button>
<button
type="button"
onClick={onNext}
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/90 p-2 text-slate-700 shadow transition hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
title="Next image"
>
<ChevronRight aria-hidden className="h-5 w-5" />
<span className="sr-only">Next image</span>
</button>
</>
) : null}
</>
) : (
<p className="px-4 text-center text-sm text-slate-500">
No images have been added for this item yet.
</p>
)}
</div>
</div>
</div>
);
}
@@ -4,6 +4,7 @@ interface ItemThumbnailProps {
url: string | null;
alt: string;
size?: 'sm' | 'md';
onClick?: () => void;
}
const sizeClassMap: Record<Required<ItemThumbnailProps>['size'], string> = {
@@ -11,28 +12,40 @@ const sizeClassMap: Record<Required<ItemThumbnailProps>['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 (
<div
className={`flex-shrink-0 overflow-hidden rounded-md border border-slate-200 bg-slate-100 ${sizeClasses}`}
>
{hasImage ? (
<img
src={url ?? ''}
alt={alt}
className="h-full w-full object-cover"
loading="lazy"
onError={() => setHasError(true)}
/>
) : (
<div className="flex h-full w-full items-center justify-center px-2 text-center text-[0.625rem] font-medium uppercase tracking-wide text-slate-400">
No Image
</div>
)}
const content = hasImage ? (
<img
src={url ?? ''}
alt={alt}
className="h-full w-full object-cover"
loading="lazy"
onError={() => setHasError(true)}
/>
) : (
<div className="flex h-full w-full items-center justify-center px-2 text-center text-[0.625rem] font-medium uppercase tracking-wide text-slate-400">
No Image
</div>
);
if (onClick) {
return (
<button
type="button"
onClick={onClick}
className={containerClasses}
aria-label={`View images for ${alt}`}
>
{content}
</button>
);
}
return <div className={containerClasses}>{content}</div>;
}
@@ -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<string | null>(null);
const [isSavingItem, setIsSavingItem] = useState(false);
const [isGalleryOpen, setIsGalleryOpen] = useState(false);
const [galleryItemName, setGalleryItemName] = useState('');
const [galleryImages, setGalleryImages] = useState<GalleryImage[]>([]);
const [galleryActiveIndex, setGalleryActiveIndex] = useState(0);
const [isGalleryLoading, setIsGalleryLoading] = useState(false);
const [galleryError, setGalleryError] = useState<string | null>(null);
const galleryCacheRef = useRef(
new Map<string, { images: GalleryImage[]; defaultImageId: string | null }>()
);
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}
/>
<ItemFormModal
@@ -482,6 +614,18 @@ export function ProjectDetailPage() {
isInitialDataLoading={itemModalInitialDataLoading}
initialDataError={itemModalInitialDataError}
/>
<ItemImageGalleryOverlay
isOpen={isGalleryOpen}
itemName={galleryItemName}
images={galleryImages}
activeIndex={galleryActiveIndex}
isLoading={isGalleryLoading}
error={galleryError}
onClose={handleCloseGallery}
onNext={handleGalleryNext}
onPrevious={handleGalleryPrevious}
/>
</div>
);
}
@@ -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 (
<div className="hidden md:block">
@@ -59,38 +61,43 @@ export function ProjectItemsDesktopTable({
<tr className={`hover:bg-slate-50 ${isExpanded ? 'bg-slate-50' : ''}`}>
<td className="px-4 py-3 align-top">
<div className="flex items-start gap-3">
<ItemThumbnail url={item.defaultImageUrl} alt={displayName} size="sm" />
<ItemThumbnail
url={item.defaultImageUrl}
alt={displayName}
size="sm"
onClick={() => onViewItemImages(item)}
/>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => 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"
>
<span
aria-hidden
className={`inline-block h-2.5 w-2.5 rounded-full ${
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
}`}
/>
<span className="font-semibold group-hover:underline">
{displayName}
</span>
</button>
{item.sourceUrl ? (
<a
href={item.sourceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-blue-600 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
title="Open source link"
<button
type="button"
onClick={() => 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"
>
<ExternalLink aria-hidden className="h-4 w-4" />
<span className="sr-only">Open source link</span>
</a>
) : null}
</div>
<span
aria-hidden
className={`inline-block h-2.5 w-2.5 rounded-full ${
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
}`}
/>
<span className="font-semibold group-hover:underline">
{displayName}
</span>
</button>
{item.sourceUrl ? (
<a
href={item.sourceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-blue-600 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
title="Open source link"
>
<ExternalLink aria-hidden className="h-4 w-4" />
<span className="sr-only">Open source link</span>
</a>
) : null}
</div>
{item.note ? (
<span className="text-xs text-slate-500">{item.note}</span>
) : null}
@@ -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 (
<div className="space-y-4 md:hidden">
@@ -33,7 +35,12 @@ export function ProjectItemsMobileList({
return (
<div key={item.id} className="rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="flex items-start gap-4">
<ItemThumbnail url={item.defaultImageUrl} alt={displayName} size="md" />
<ItemThumbnail
url={item.defaultImageUrl}
alt={displayName}
size="md"
onClick={() => onViewItemImages(item)}
/>
<div className="flex flex-1 flex-col gap-2">
<div className="flex items-start gap-2">
<button
@@ -19,6 +19,7 @@ interface ProjectItemsSectionProps {
isImportingFromUrl: boolean;
onEditItem: (item: Item) => void;
projectId: string | undefined;
onViewItemImages: (item: Item) => void;
}
export function ProjectItemsSection({
@@ -33,7 +34,8 @@ export function ProjectItemsSection({
onAddManual,
isImportingFromUrl,
onEditItem,
projectId
projectId,
onViewItemImages
}: ProjectItemsSectionProps) {
const [showDifferencesOnly, setShowDifferencesOnly] = useState(false);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
@@ -134,6 +136,7 @@ export function ProjectItemsSection({
onTogglePrices={handleTogglePrices}
onEditItem={onEditItem}
projectId={projectId}
onViewItemImages={onViewItemImages}
/>
<ProjectItemsMobileList
@@ -143,6 +146,7 @@ export function ProjectItemsSection({
onTogglePrices={handleTogglePrices}
onEditItem={onEditItem}
projectId={projectId}
onViewItemImages={onViewItemImages}
/>
</section>