feat: enhance image preview components with additional item handling and navigation features

This commit is contained in:
Yoga Pangestu 2026-08-12 23:58:00 +07:00
parent fbdad6923c
commit 6c1c14c588
7 changed files with 130 additions and 26 deletions

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { ImagePreviewModal } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs/image-preview-modal';
type ImagePreviewButtonProps = {
srcs: string[];
@ -9,6 +10,8 @@ type ImagePreviewButtonProps = {
description?: string;
alt?: string;
className?: string;
items?: ImagePreviewItem[];
startIndex?: number;
};
export function ImagePreviewButton({
@ -19,6 +22,8 @@ export function ImagePreviewButton({
description,
alt,
className = 'h-10 w-10',
items,
startIndex = 0,
}: ImagePreviewButtonProps) {
const [open, setOpen] = useState(false);
@ -49,6 +54,8 @@ export function ImagePreviewButton({
sources={modalSources ?? srcs}
title={title}
description={description}
items={items}
startIndex={startIndex}
/>
</>
);

View File

@ -1,13 +1,22 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTitle
} from '@/components/ui/dialog';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useEffect, useState } from 'react';
export type ImagePreviewItem = {
id?: string | number;
src: string | null;
sources?: string[];
title?: string;
description?: string;
};
type ImagePreviewModalProps = {
open: boolean;
@ -17,6 +26,8 @@ type ImagePreviewModalProps = {
description?: string;
alt?: string;
sources?: string[];
items?: ImagePreviewItem[];
startIndex?: number;
};
export function ImagePreviewModal({
@ -27,23 +38,49 @@ export function ImagePreviewModal({
description,
alt = 'Preview',
sources,
items,
startIndex = 0,
}: ImagePreviewModalProps) {
const allImages = sources && sources.length > 0 ? sources : src ? [src] : [];
const [currentIndex, setCurrentIndex] = useState(0);
const [itemIndex, setItemIndex] = useState(startIndex);
const [imageIndex, setImageIndex] = useState(0);
const currentSrc = allImages[currentIndex] ?? src;
const hasMultiple = allImages.length > 1;
useEffect(() => {
if (open) {
setItemIndex(startIndex);
setImageIndex(0);
}
}, [open, startIndex]);
function handlePrev() {
setCurrentIndex((prev) =>
prev === 0 ? allImages.length - 1 : prev - 1,
);
useEffect(() => {
setImageIndex(0);
}, [itemIndex]);
const isMultiItem = items && items.length > 1;
const currentItem = isMultiItem ? items[itemIndex] : null;
const allImages = currentItem
? (currentItem.sources?.length ? currentItem.sources : currentItem.src ? [currentItem.src] : [])
: (sources?.length ? sources : src ? [src] : []);
const currentSrc = allImages[imageIndex] ?? allImages[0] ?? currentItem?.src ?? src;
const currentTitle = currentItem?.title ?? title;
const currentDescription = currentItem?.description ?? description;
const hasMultipleImages = allImages.length > 1;
function handleImagePrev() {
setImageIndex((prev) => prev === 0 ? allImages.length - 1 : prev - 1);
}
function handleNext() {
setCurrentIndex((prev) =>
prev === allImages.length - 1 ? 0 : prev + 1,
);
function handleImageNext() {
setImageIndex((prev) => prev === allImages.length - 1 ? 0 : prev + 1);
}
function handleItemPrev() {
setItemIndex((prev) => prev === 0 ? items.length - 1 : prev - 1);
}
function handleItemNext() {
setItemIndex((prev) => prev === items.length - 1 ? 0 : prev + 1);
}
return (
@ -51,17 +88,17 @@ export function ImagePreviewModal({
open={open}
onOpenChange={(v) => {
if (!v) {
setCurrentIndex(0);
}
setItemIndex(startIndex);
setImageIndex(0);
}
onOpenChange(v);
}}
>
<DialogContent showCloseButton>
{(title || description) && (
{(currentTitle || currentDescription) && (
<DialogHeader>
{title && <DialogTitle>{title}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>}
{currentTitle && <DialogTitle>{currentTitle}</DialogTitle>}
{currentDescription && <DialogDescription>{currentDescription}</DialogDescription>}
</DialogHeader>
)}
{currentSrc && (
@ -71,13 +108,13 @@ setCurrentIndex(0);
alt={alt}
className="max-h-[80vh] w-full rounded-lg object-contain"
/>
{hasMultiple && (
{hasMultipleImages && (
<>
<Button
variant="secondary"
size="icon"
className="absolute left-2 top-1/2 h-8 w-8 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
onClick={handlePrev}
onClick={handleImagePrev}
>
<ChevronLeft className="h-4 w-4" />
</Button>
@ -85,17 +122,29 @@ setCurrentIndex(0);
variant="secondary"
size="icon"
className="absolute right-2 top-1/2 h-8 w-8 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
onClick={handleNext}
onClick={handleImageNext}
>
<ChevronRight className="h-4 w-4" />
</Button>
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-1 text-xs text-white">
{currentIndex + 1} / {allImages.length}
{imageIndex + 1} / {allImages.length}
</div>
</>
)}
</div>
)}
{isMultiItem && (
<DialogFooter>
<Button variant="outline" onClick={handleItemPrev}>
<ChevronLeft className="h-4 w-4 mr-1" />
Sebelumnya
</Button>
<Button variant="outline" onClick={handleItemNext}>
Selanjutnya
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);

View File

@ -3,3 +3,4 @@ export { DeleteConfirmDialog } from './delete-confirm-dialog';
export { FormDialog } from './form-dialog';
export { ImagePreviewButton } from './image-preview-button';
export { ImagePreviewModal } from './image-preview-modal';
export type { ImagePreviewItem } from './image-preview-modal';

View File

@ -4,6 +4,7 @@ import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { CardTable } from '@/components/data-display';
import { DeleteConfirmDialog } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs';
import { FilterPopover } from '@/components/data-display';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/layout';
@ -103,6 +104,22 @@ export default function ProductIndex({ products, categories, productNames, filte
[productNames],
);
const allPreviewItems: ImagePreviewItem[] = useMemo(
() =>
products.data.flatMap((product) =>
(product.product_variants ?? [])
.filter((v) => v.photo_urls?.length > 0)
.map((v) => ({
id: `${product.id}-${v.id}`,
src: v.photo_urls[0],
sources: v.photo_urls,
title: v.name,
description: `Stok Bagus: ${v.formatted_stock} | Stok Reject: ${v.formatted_reject_stock} | Stok Ecer: ${v.formatted_retail_stock}`,
}))
),
[products.data],
);
const selectedCategory = useMemo(
() => categories.find((c) => String(c.id) === filters.category) ?? null,
[categories, filters.category],
@ -311,6 +328,7 @@ export default function ProductIndex({ products, categories, productNames, filte
renderSubContent={(product) => (
<VariantSubRow
product={product}
allPreviewItems={allPreviewItems}
onEditVariant={(p, v) => {
router.visit(
variantEdit.url({

View File

@ -2,6 +2,7 @@ import { router } from '@inertiajs/react';
import { ArrowRightLeft, Pencil, ScrollText, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ImagePreviewButton } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs';
import { RowActions } from '@/components/data-display';
import {
Table,
@ -20,10 +21,12 @@ import { TransferStockDialog } from './transfer-stock-dialog';
export function VariantSubRow({
product,
allPreviewItems,
onEditVariant,
onDeleteVariantClick,
}: {
product: Product;
allPreviewItems: ImagePreviewItem[];
onEditVariant: (product: Product, variant: ProductVariant) => void;
onDeleteVariantClick: (product: Product, variant: ProductVariant) => void;
}) {
@ -94,6 +97,8 @@ export function VariantSubRow({
modalSrcs={variant.photo_urls}
title={variant.name}
description={`Stok Bagus: ${variant.formatted_stock} | Stok Reject: ${variant.formatted_reject_stock} | Stok Ecer: ${variant.formatted_retail_stock}`}
items={allPreviewItems}
startIndex={allPreviewItems.findIndex((item) => item.id === `${product.id}-${variant.id}`)}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">

View File

@ -3,6 +3,7 @@ import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { CardTable } from '@/components/data-display';
import { DeleteConfirmDialog } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs';
import { FilterPopover } from '@/components/data-display';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/layout';
@ -91,6 +92,21 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
[rawMaterialNames],
);
const allPreviewItems: ImagePreviewItem[] = useMemo(
() =>
rawMaterials.data.flatMap((rm) =>
(rm.raw_material_prices ?? [])
.filter((v) => v.photo_url)
.map((v) => ({
id: `${rm.id}-${v.id}`,
src: v.photo_url,
title: v.variant,
description: `Stok: ${v.formatted_stock}`,
}))
),
[rawMaterials.data],
);
function handleDelete() {
if (!deleting) {
return;
@ -246,7 +262,10 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
/>
)}
renderSubContent={(rawMaterial) => (
<RawMaterialVariantSubRow rawMaterial={rawMaterial} />
<RawMaterialVariantSubRow
rawMaterial={rawMaterial}
allPreviewItems={allPreviewItems}
/>
)}
/>

View File

@ -3,6 +3,7 @@ import { Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ConfirmDialog } from '@/components/dialogs';
import { ImagePreviewButton } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs';
import { RowActions } from '@/components/data-display';
import {
Table,
@ -23,8 +24,10 @@ import type { RawMaterial, RawMaterialVariant } from '../columns';
export function RawMaterialVariantSubRow({
rawMaterial,
allPreviewItems,
}: {
rawMaterial: RawMaterial;
allPreviewItems: ImagePreviewItem[];
}) {
const { can } = useCan();
const variants = rawMaterial.raw_material_prices ?? [];
@ -91,6 +94,8 @@ export function RawMaterialVariantSubRow({
modalSrc={variant.photo_url}
title={variant.variant}
description={`Stok: ${variant.formatted_stock}`}
items={allPreviewItems}
startIndex={allPreviewItems.findIndex((item) => item.id === `${rawMaterial.id}-${variant.id}`)}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">