mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
feat: add item gallery overlay
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user