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;
|
url: string | null;
|
||||||
alt: string;
|
alt: string;
|
||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md';
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeClassMap: Record<Required<ItemThumbnailProps>['size'], string> = {
|
const sizeClassMap: Record<Required<ItemThumbnailProps>['size'], string> = {
|
||||||
@@ -11,16 +12,15 @@ const sizeClassMap: Record<Required<ItemThumbnailProps>['size'], string> = {
|
|||||||
md: 'h-20 w-20'
|
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 [hasError, setHasError] = useState(false);
|
||||||
const hasImage = !!url && !hasError;
|
const hasImage = !!url && !hasError;
|
||||||
const sizeClasses = sizeClassMap[size];
|
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 (
|
const content = hasImage ? (
|
||||||
<div
|
|
||||||
className={`flex-shrink-0 overflow-hidden rounded-md border border-slate-200 bg-slate-100 ${sizeClasses}`}
|
|
||||||
>
|
|
||||||
{hasImage ? (
|
|
||||||
<img
|
<img
|
||||||
src={url ?? ''}
|
src={url ?? ''}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
@@ -32,7 +32,20 @@ export function ItemThumbnail({ url, alt, size = 'sm' }: ItemThumbnailProps) {
|
|||||||
<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">
|
<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
|
No Image
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</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 { useParams } from 'react-router-dom';
|
||||||
import type { Item } from '@shared/models/item';
|
import type { Item } from '@shared/models/item';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -22,6 +22,7 @@ import { ProjectHeader } from './project-header';
|
|||||||
import { ProjectDescriptionSection } from './project-description-section';
|
import { ProjectDescriptionSection } from './project-description-section';
|
||||||
import { TrackedAttributesSection } from './tracked-attributes-section';
|
import { TrackedAttributesSection } from './tracked-attributes-section';
|
||||||
import { ProjectItemsSection } from './project-items-section';
|
import { ProjectItemsSection } from './project-items-section';
|
||||||
|
import { ItemImageGalleryOverlay, type GalleryImage } from './item-image-gallery-overlay';
|
||||||
|
|
||||||
export function ProjectDetailPage() {
|
export function ProjectDetailPage() {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
@@ -47,6 +48,16 @@ export function ProjectDetailPage() {
|
|||||||
const [isLoadingItemDetails, setIsLoadingItemDetails] = useState(false);
|
const [isLoadingItemDetails, setIsLoadingItemDetails] = useState(false);
|
||||||
const [editItemError, setEditItemError] = useState<string | null>(null);
|
const [editItemError, setEditItemError] = useState<string | null>(null);
|
||||||
const [isSavingItem, setIsSavingItem] = useState(false);
|
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 project = projectQuery.data;
|
||||||
const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.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));
|
return Array.from(attributeSet).sort((a, b) => a.localeCompare(b));
|
||||||
}, [projectAttributes, items]);
|
}, [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) => {
|
const openItemModal = (mode: 'url' | 'manual' | 'edit', initialUrl: string | null) => {
|
||||||
setItemModalMode(mode);
|
setItemModalMode(mode);
|
||||||
setModalInitialUrl(initialUrl);
|
setModalInitialUrl(initialUrl);
|
||||||
@@ -467,6 +598,7 @@ export function ProjectDetailPage() {
|
|||||||
isImportingFromUrl={isImportingItem}
|
isImportingFromUrl={isImportingItem}
|
||||||
onEditItem={handleEditItem}
|
onEditItem={handleEditItem}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
onViewItemImages={handleViewItemImages}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ItemFormModal
|
<ItemFormModal
|
||||||
@@ -482,6 +614,18 @@ export function ProjectDetailPage() {
|
|||||||
isInitialDataLoading={itemModalInitialDataLoading}
|
isInitialDataLoading={itemModalInitialDataLoading}
|
||||||
initialDataError={itemModalInitialDataError}
|
initialDataError={itemModalInitialDataError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ItemImageGalleryOverlay
|
||||||
|
isOpen={isGalleryOpen}
|
||||||
|
itemName={galleryItemName}
|
||||||
|
images={galleryImages}
|
||||||
|
activeIndex={galleryActiveIndex}
|
||||||
|
isLoading={isGalleryLoading}
|
||||||
|
error={galleryError}
|
||||||
|
onClose={handleCloseGallery}
|
||||||
|
onNext={handleGalleryNext}
|
||||||
|
onPrevious={handleGalleryPrevious}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface ProjectItemsDesktopTableProps {
|
|||||||
onTogglePrices: (itemId: string) => void;
|
onTogglePrices: (itemId: string) => void;
|
||||||
onEditItem: (item: Item) => void;
|
onEditItem: (item: Item) => void;
|
||||||
projectId: string | undefined;
|
projectId: string | undefined;
|
||||||
|
onViewItemImages: (item: Item) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectItemsDesktopTable({
|
export function ProjectItemsDesktopTable({
|
||||||
@@ -20,7 +21,8 @@ export function ProjectItemsDesktopTable({
|
|||||||
expandedItemId,
|
expandedItemId,
|
||||||
onTogglePrices,
|
onTogglePrices,
|
||||||
onEditItem,
|
onEditItem,
|
||||||
projectId
|
projectId,
|
||||||
|
onViewItemImages
|
||||||
}: ProjectItemsDesktopTableProps) {
|
}: ProjectItemsDesktopTableProps) {
|
||||||
return (
|
return (
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
@@ -59,7 +61,12 @@ export function ProjectItemsDesktopTable({
|
|||||||
<tr className={`hover:bg-slate-50 ${isExpanded ? 'bg-slate-50' : ''}`}>
|
<tr className={`hover:bg-slate-50 ${isExpanded ? 'bg-slate-50' : ''}`}>
|
||||||
<td className="px-4 py-3 align-top">
|
<td className="px-4 py-3 align-top">
|
||||||
<div className="flex items-start gap-3">
|
<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 flex-1 flex-col gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface ProjectItemsMobileListProps {
|
|||||||
onTogglePrices: (itemId: string) => void;
|
onTogglePrices: (itemId: string) => void;
|
||||||
onEditItem: (item: Item) => void;
|
onEditItem: (item: Item) => void;
|
||||||
projectId: string | undefined;
|
projectId: string | undefined;
|
||||||
|
onViewItemImages: (item: Item) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectItemsMobileList({
|
export function ProjectItemsMobileList({
|
||||||
@@ -19,7 +20,8 @@ export function ProjectItemsMobileList({
|
|||||||
expandedItemId,
|
expandedItemId,
|
||||||
onTogglePrices,
|
onTogglePrices,
|
||||||
onEditItem,
|
onEditItem,
|
||||||
projectId
|
projectId,
|
||||||
|
onViewItemImages
|
||||||
}: ProjectItemsMobileListProps) {
|
}: ProjectItemsMobileListProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:hidden">
|
<div className="space-y-4 md:hidden">
|
||||||
@@ -33,7 +35,12 @@ export function ProjectItemsMobileList({
|
|||||||
return (
|
return (
|
||||||
<div key={item.id} className="rounded-xl border border-slate-200 p-4 shadow-sm">
|
<div key={item.id} className="rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||||
<div className="flex items-start gap-4">
|
<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 flex-1 flex-col gap-2">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface ProjectItemsSectionProps {
|
|||||||
isImportingFromUrl: boolean;
|
isImportingFromUrl: boolean;
|
||||||
onEditItem: (item: Item) => void;
|
onEditItem: (item: Item) => void;
|
||||||
projectId: string | undefined;
|
projectId: string | undefined;
|
||||||
|
onViewItemImages: (item: Item) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectItemsSection({
|
export function ProjectItemsSection({
|
||||||
@@ -33,7 +34,8 @@ export function ProjectItemsSection({
|
|||||||
onAddManual,
|
onAddManual,
|
||||||
isImportingFromUrl,
|
isImportingFromUrl,
|
||||||
onEditItem,
|
onEditItem,
|
||||||
projectId
|
projectId,
|
||||||
|
onViewItemImages
|
||||||
}: ProjectItemsSectionProps) {
|
}: ProjectItemsSectionProps) {
|
||||||
const [showDifferencesOnly, setShowDifferencesOnly] = useState(false);
|
const [showDifferencesOnly, setShowDifferencesOnly] = useState(false);
|
||||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
||||||
@@ -134,6 +136,7 @@ export function ProjectItemsSection({
|
|||||||
onTogglePrices={handleTogglePrices}
|
onTogglePrices={handleTogglePrices}
|
||||||
onEditItem={onEditItem}
|
onEditItem={onEditItem}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
onViewItemImages={onViewItemImages}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProjectItemsMobileList
|
<ProjectItemsMobileList
|
||||||
@@ -143,6 +146,7 @@ export function ProjectItemsSection({
|
|||||||
onTogglePrices={handleTogglePrices}
|
onTogglePrices={handleTogglePrices}
|
||||||
onEditItem={onEditItem}
|
onEditItem={onEditItem}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
onViewItemImages={onViewItemImages}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user