feat: enhance image preview components with additional item handling and navigation features
This commit is contained in:
parent
fbdad6923c
commit
6c1c14c588
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user