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 { useState } from 'react';
import { ImagePreviewModal } from '@/components/dialogs'; import { ImagePreviewModal } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs/image-preview-modal';
type ImagePreviewButtonProps = { type ImagePreviewButtonProps = {
srcs: string[]; srcs: string[];
@ -9,6 +10,8 @@ type ImagePreviewButtonProps = {
description?: string; description?: string;
alt?: string; alt?: string;
className?: string; className?: string;
items?: ImagePreviewItem[];
startIndex?: number;
}; };
export function ImagePreviewButton({ export function ImagePreviewButton({
@ -19,6 +22,8 @@ export function ImagePreviewButton({
description, description,
alt, alt,
className = 'h-10 w-10', className = 'h-10 w-10',
items,
startIndex = 0,
}: ImagePreviewButtonProps) { }: ImagePreviewButtonProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -49,6 +54,8 @@ export function ImagePreviewButton({
sources={modalSources ?? srcs} sources={modalSources ?? srcs}
title={title} title={title}
description={description} 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 { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle
} from '@/components/ui/dialog'; } 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 = { type ImagePreviewModalProps = {
open: boolean; open: boolean;
@ -17,6 +26,8 @@ type ImagePreviewModalProps = {
description?: string; description?: string;
alt?: string; alt?: string;
sources?: string[]; sources?: string[];
items?: ImagePreviewItem[];
startIndex?: number;
}; };
export function ImagePreviewModal({ export function ImagePreviewModal({
@ -27,23 +38,49 @@ export function ImagePreviewModal({
description, description,
alt = 'Preview', alt = 'Preview',
sources, sources,
items,
startIndex = 0,
}: ImagePreviewModalProps) { }: ImagePreviewModalProps) {
const allImages = sources && sources.length > 0 ? sources : src ? [src] : []; const [itemIndex, setItemIndex] = useState(startIndex);
const [currentIndex, setCurrentIndex] = useState(0); const [imageIndex, setImageIndex] = useState(0);
const currentSrc = allImages[currentIndex] ?? src; useEffect(() => {
const hasMultiple = allImages.length > 1; if (open) {
setItemIndex(startIndex);
setImageIndex(0);
}
}, [open, startIndex]);
function handlePrev() { useEffect(() => {
setCurrentIndex((prev) => setImageIndex(0);
prev === 0 ? allImages.length - 1 : prev - 1, }, [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() { function handleImageNext() {
setCurrentIndex((prev) => setImageIndex((prev) => prev === allImages.length - 1 ? 0 : prev + 1);
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 ( return (
@ -51,17 +88,17 @@ export function ImagePreviewModal({
open={open} open={open}
onOpenChange={(v) => { onOpenChange={(v) => {
if (!v) { if (!v) {
setCurrentIndex(0); setItemIndex(startIndex);
} setImageIndex(0);
}
onOpenChange(v); onOpenChange(v);
}} }}
> >
<DialogContent showCloseButton> <DialogContent showCloseButton>
{(title || description) && ( {(currentTitle || currentDescription) && (
<DialogHeader> <DialogHeader>
{title && <DialogTitle>{title}</DialogTitle>} {currentTitle && <DialogTitle>{currentTitle}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>} {currentDescription && <DialogDescription>{currentDescription}</DialogDescription>}
</DialogHeader> </DialogHeader>
)} )}
{currentSrc && ( {currentSrc && (
@ -71,13 +108,13 @@ setCurrentIndex(0);
alt={alt} alt={alt}
className="max-h-[80vh] w-full rounded-lg object-contain" className="max-h-[80vh] w-full rounded-lg object-contain"
/> />
{hasMultiple && ( {hasMultipleImages && (
<> <>
<Button <Button
variant="secondary" variant="secondary"
size="icon" 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" 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" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
@ -85,17 +122,29 @@ setCurrentIndex(0);
variant="secondary" variant="secondary"
size="icon" 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" 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" /> <ChevronRight className="h-4 w-4" />
</Button> </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"> <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>
</> </>
)} )}
</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> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -3,3 +3,4 @@ export { DeleteConfirmDialog } from './delete-confirm-dialog';
export { FormDialog } from './form-dialog'; export { FormDialog } from './form-dialog';
export { ImagePreviewButton } from './image-preview-button'; export { ImagePreviewButton } from './image-preview-button';
export { ImagePreviewModal } from './image-preview-modal'; 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 { useMemo, useState } from 'react';
import { CardTable } from '@/components/data-display'; import { CardTable } from '@/components/data-display';
import { DeleteConfirmDialog } from '@/components/dialogs'; import { DeleteConfirmDialog } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs';
import { FilterPopover } from '@/components/data-display'; import { FilterPopover } from '@/components/data-display';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/layout'; import { PageHeader } from '@/components/layout';
@ -103,6 +104,22 @@ export default function ProductIndex({ products, categories, productNames, filte
[productNames], [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( const selectedCategory = useMemo(
() => categories.find((c) => String(c.id) === filters.category) ?? null, () => categories.find((c) => String(c.id) === filters.category) ?? null,
[categories, filters.category], [categories, filters.category],
@ -311,6 +328,7 @@ export default function ProductIndex({ products, categories, productNames, filte
renderSubContent={(product) => ( renderSubContent={(product) => (
<VariantSubRow <VariantSubRow
product={product} product={product}
allPreviewItems={allPreviewItems}
onEditVariant={(p, v) => { onEditVariant={(p, v) => {
router.visit( router.visit(
variantEdit.url({ variantEdit.url({

View File

@ -2,6 +2,7 @@ import { router } from '@inertiajs/react';
import { ArrowRightLeft, Pencil, ScrollText, Trash2 } from 'lucide-react'; import { ArrowRightLeft, Pencil, ScrollText, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { ImagePreviewButton } from '@/components/dialogs'; import { ImagePreviewButton } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs';
import { RowActions } from '@/components/data-display'; import { RowActions } from '@/components/data-display';
import { import {
Table, Table,
@ -20,10 +21,12 @@ import { TransferStockDialog } from './transfer-stock-dialog';
export function VariantSubRow({ export function VariantSubRow({
product, product,
allPreviewItems,
onEditVariant, onEditVariant,
onDeleteVariantClick, onDeleteVariantClick,
}: { }: {
product: Product; product: Product;
allPreviewItems: ImagePreviewItem[];
onEditVariant: (product: Product, variant: ProductVariant) => void; onEditVariant: (product: Product, variant: ProductVariant) => void;
onDeleteVariantClick: (product: Product, variant: ProductVariant) => void; onDeleteVariantClick: (product: Product, variant: ProductVariant) => void;
}) { }) {
@ -94,6 +97,8 @@ export function VariantSubRow({
modalSrcs={variant.photo_urls} modalSrcs={variant.photo_urls}
title={variant.name} title={variant.name}
description={`Stok Bagus: ${variant.formatted_stock} | Stok Reject: ${variant.formatted_reject_stock} | Stok Ecer: ${variant.formatted_retail_stock}`} 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"> <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 { useMemo, useState } from 'react';
import { CardTable } from '@/components/data-display'; import { CardTable } from '@/components/data-display';
import { DeleteConfirmDialog } from '@/components/dialogs'; import { DeleteConfirmDialog } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs';
import { FilterPopover } from '@/components/data-display'; import { FilterPopover } from '@/components/data-display';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/layout'; import { PageHeader } from '@/components/layout';
@ -91,6 +92,21 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
[rawMaterialNames], [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() { function handleDelete() {
if (!deleting) { if (!deleting) {
return; return;
@ -246,7 +262,10 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
/> />
)} )}
renderSubContent={(rawMaterial) => ( 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 { useState } from 'react';
import { ConfirmDialog } from '@/components/dialogs'; import { ConfirmDialog } from '@/components/dialogs';
import { ImagePreviewButton } from '@/components/dialogs'; import { ImagePreviewButton } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs';
import { RowActions } from '@/components/data-display'; import { RowActions } from '@/components/data-display';
import { import {
Table, Table,
@ -23,8 +24,10 @@ import type { RawMaterial, RawMaterialVariant } from '../columns';
export function RawMaterialVariantSubRow({ export function RawMaterialVariantSubRow({
rawMaterial, rawMaterial,
allPreviewItems,
}: { }: {
rawMaterial: RawMaterial; rawMaterial: RawMaterial;
allPreviewItems: ImagePreviewItem[];
}) { }) {
const { can } = useCan(); const { can } = useCan();
const variants = rawMaterial.raw_material_prices ?? []; const variants = rawMaterial.raw_material_prices ?? [];
@ -91,6 +94,8 @@ export function RawMaterialVariantSubRow({
modalSrc={variant.photo_url} modalSrc={variant.photo_url}
title={variant.variant} title={variant.variant}
description={`Stok: ${variant.formatted_stock}`} 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"> <div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">