feat: enhance media preview functionality in catalog components by adding support for multiple image URLs and custom titles
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

This commit is contained in:
Yoga Pangestu 2026-07-07 12:36:45 +07:00
parent 3792424d52
commit f1f9162304
11 changed files with 323 additions and 91 deletions

View File

@ -1,16 +1,30 @@
<script setup lang="ts">
import { Package } from '@lucide/vue';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
import type { MediaItem } from '@/types/media';
const props = defineProps<{
items?: MediaItem[];
const props = withDefaults(
defineProps<{
items?: MediaItem[];
customPreview?: boolean;
title?: string;
}>(),
{
items: () => [],
customPreview: false,
}
);
const emit = defineEmits<{
'click-thumb': [event: MouseEvent];
}>();
const previewOpen = ref(false);
const previewUrl = ref<string | null>(null);
const urls = computed(() => props.items?.map((item) => item.url) ?? []);
function openPreview() {
if (!props.items?.length) {
return;
@ -19,6 +33,14 @@ function openPreview() {
previewUrl.value = props.items[0].url;
previewOpen.value = true;
}
function handleClick(e: MouseEvent) {
if (props.customPreview) {
emit('click-thumb', e);
} else {
openPreview();
}
}
</script>
<template>
@ -27,7 +49,7 @@ function openPreview() {
class="size-10 shrink-0 overflow-hidden rounded border bg-muted/30"
:class="items?.length ? 'cursor-zoom-in' : 'cursor-default'"
:disabled="!items?.length"
@click.stop="openPreview"
@click.stop="handleClick"
>
<img
v-if="items?.length"
@ -43,5 +65,5 @@ function openPreview() {
</div>
</button>
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" />
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="urls" :title="title" />
</template>

View File

@ -1,5 +1,7 @@
<script setup lang="ts">
import { watch } from 'vue';
import { ChevronLeft, ChevronRight } from '@lucide/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@ -11,28 +13,153 @@ const open = defineModel<boolean>('open', { default: false });
const emit = defineEmits<{
close: [];
'update:url': [url: string];
}>();
defineProps<{
url: string | null;
title?: string;
}>();
const props = withDefaults(
defineProps<{
url: string | null;
urls?: string[];
title?: string;
}>(),
{
urls: () => [],
title: 'Pratinjau Foto',
}
);
const activeUrl = ref<string | null>(props.url);
watch(
() => props.url,
(newUrl) => {
activeUrl.value = newUrl;
}
);
watch(open, (val) => {
if (!val) {
emit('close');
} else {
activeUrl.value = props.url;
}
});
const allUrls = computed(() => {
if (props.urls && props.urls.length > 0) {
return props.urls;
}
return props.url ? [props.url] : [];
});
const currentIndex = computed(() => {
if (!activeUrl.value) {
return -1;
}
return allUrls.value.indexOf(activeUrl.value);
});
const hasMultiple = computed(() => allUrls.value.length > 1);
function nextImage() {
if (!hasMultiple.value) {
return;
}
const nextIdx = (currentIndex.value + 1) % allUrls.value.length;
const newUrl = allUrls.value[nextIdx];
activeUrl.value = newUrl;
emit('update:url', newUrl);
}
function prevImage() {
if (!hasMultiple.value) {
return;
}
const prevIdx = (currentIndex.value - 1 + allUrls.value.length) % allUrls.value.length;
const newUrl = allUrls.value[prevIdx];
activeUrl.value = newUrl;
emit('update:url', newUrl);
}
function handleKeyDown(e: KeyboardEvent) {
if (!open.value || !hasMultiple.value) {
return;
}
if (e.key === 'ArrowRight') {
nextImage();
} else if (e.key === 'ArrowLeft') {
prevImage();
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
});
let touchStartX = 0;
let touchEndX = 0;
function handleTouchStart(e: TouchEvent) {
touchStartX = e.changedTouches[0].screenX;
}
function handleTouchEnd(e: TouchEvent) {
touchEndX = e.changedTouches[0].screenX;
const diff = touchStartX - touchEndX;
if (Math.abs(diff) > 50) {
if (diff > 0) {
nextImage();
} else {
prevImage();
}
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-wd p-2 sm:p-4">
<DialogHeader class="sr-only">
<DialogTitle>{{ title ?? 'Pratinjau Gambar' }}</DialogTitle>
<DialogContent class="sm:max-w-2xl p-4 overflow-hidden gap-4">
<DialogHeader class="pb-2 border-b">
<DialogTitle class="text-base font-semibold">{{ title ?? 'Pratinjau Gambar' }}</DialogTitle>
</DialogHeader>
<img v-if="url" :src="url" :alt="title ?? 'Pratinjau gambar'"
class="max-h-[80vh] w-full rounded-md object-contain">
<div class="relative flex items-center justify-center min-h-[300px] select-none"
@touchstart="handleTouchStart" @touchend="handleTouchEnd">
<img v-if="activeUrl" :key="activeUrl" :src="activeUrl" :alt="title ?? 'Pratinjau gambar'"
class="max-h-[70vh] w-full rounded-md object-contain transition-all duration-300">
</div>
<!-- Bottom Navigation Bar -->
<div v-if="hasMultiple" class="mt-4 flex items-center justify-between gap-4 border-t pt-3">
<Button type="button" variant="outline" size="sm" class="flex items-center gap-1 shrink-0"
@click.stop="prevImage">
<ChevronLeft class="size-4" />
Sebelumnya
</Button>
<div class="flex flex-wrap justify-center gap-1.5">
<button v-for="(_, idx) in allUrls" :key="idx" type="button"
class="size-2 rounded-full transition-all duration-200"
:class="idx === currentIndex ? 'bg-primary w-4' : 'bg-muted-foreground/30'"
@click="activeUrl = allUrls[idx]; emit('update:url', allUrls[idx]);" />
</div>
<Button type="button" variant="outline" size="sm" class="flex items-center gap-1 shrink-0"
@click.stop="nextImage">
Berikutnya
<ChevronRight class="size-4" />
</Button>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -12,6 +12,7 @@ import type { MediaItem } from '@/types/media';
const props = defineProps<{
items: MediaItem[];
maxVisible?: number;
title?: string;
}>();
const previewOpen = ref(false);
@ -118,5 +119,5 @@ function openGallery() {
</DialogContent>
</Dialog>
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" @close="onPreviewClose" />
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="items.map(item => item.url)" :title="title" @close="onPreviewClose" />
</template>

View File

@ -184,7 +184,7 @@ watch(open, (value) => {
class="flex flex-col gap-2 rounded-md border p-2 sm:flex-row sm:items-center"
:class="item.is_initial ? 'border-primary bg-primary/5' : ''">
<div class="flex items-center gap-2 flex-1 min-w-0">
<PosCatalogVariantThumb :items="item.images" />
<PosCatalogVariantThumb :items="item.images" :title="`${item.raw_material_name} - ${item.variant}`" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ item.raw_material_name }} - {{ item.variant }}
@ -238,7 +238,7 @@ watch(open, (value) => {
:class="[
isSelected(price.id) ? 'border-2 border-primary bg-primary/5' : 'cursor-pointer hover:bg-muted/30',
]" @click="!isSelected(price.id) && toggleVariant(rawMaterial, price)">
<PosCatalogVariantThumb :items="price.images" />
<PosCatalogVariantThumb :items="price.images" :title="`${rawMaterial.name} - ${price.variant}`" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm">{{ price.variant }}</p>
<p class="text-xs tabular-nums text-muted-foreground">

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { Layers, Minus, Plus, Search } from '@lucide/vue';
import { ref } from 'vue';
import { computed, ref, watch } from 'vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -27,7 +28,7 @@ import CuttingPosCombinationDialog from './CuttingPosCombinationDialog.vue';
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
import type { CuttingCatalogPrice } from './useCuttingPosCart';
defineProps<{
const props = defineProps<{
filteredRawMaterials: CuttingRawMaterialCatalogItem[];
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
materialCart: CuttingMaterialCartItem[];
@ -35,6 +36,47 @@ defineProps<{
units: EnumOption[];
}>();
const previewOpen = ref(false);
const previewUrl = ref<string | null>(null);
const previewTitle = ref<string>('');
const allVariantImages = computed(() => {
const list: { priceId: number; title: string; url: string }[] = [];
props.filteredRawMaterials.forEach((rawMaterial) => {
rawMaterial.prices.forEach((price) => {
if (price.images && price.images.length > 0) {
list.push({
priceId: price.id,
title: `${rawMaterial.name} - ${price.variant}`,
url: price.images[0].url,
});
}
});
});
return list;
});
const previewUrls = computed(() => allVariantImages.value.map((item) => item.url));
function handleThumbClick(priceId: number) {
const idx = allVariantImages.value.findIndex((item) => item.priceId === priceId);
if (idx !== -1) {
previewUrl.value = allVariantImages.value[idx].url;
previewTitle.value = allVariantImages.value[idx].title;
previewOpen.value = true;
}
}
watch(previewUrl, (newUrl) => {
const matched = allVariantImages.value.find((item) => item.url === newUrl);
if (matched) {
previewTitle.value = matched.title;
}
});
const materialSearch = defineModel<string>('materialSearch', { required: true });
const selectedRawMaterialId = defineModel<string>('selectedRawMaterialId', { required: true });
@ -102,67 +144,63 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3">
<template v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id">
<PosCatalogCard v-if="rawMaterial.prices.length > 0"
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)">
<template #header-extra>
<Badge variant="secondary" class="mt-1.5">
{{ rawMaterial.unit_label }}
</Badge>
</template>
<PosCatalogCard v-if="rawMaterial.prices.length > 0" :title="rawMaterial.name"
:cover-image="getFirstCoverImage(rawMaterial.prices)">
<template #header-extra>
<Badge variant="secondary" class="mt-1.5">
{{ rawMaterial.unit_label }}
</Badge>
</template>
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div v-for="price in rawMaterial.prices" :key="price.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
'cursor-pointer hover:bg-muted/30',
getMaterialCartItem(price.id)
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
: '',
]" @click="!getMaterialCartItem(price.id) && emit('add-material', rawMaterial, price)">
<PosCatalogVariantThumb :items="price.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ price.variant }}
</p>
<p class="text-xs tabular-nums text-muted-foreground">
Stok: {{ price.stock_formatted }}
</p>
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div v-for="price in rawMaterial.prices" :key="price.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
'cursor-pointer hover:bg-muted/30',
getMaterialCartItem(price.id)
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
: '',
]" @click="!getMaterialCartItem(price.id) && emit('add-material', rawMaterial, price)">
<PosCatalogVariantThumb :items="price.images" custom-preview
@click-thumb="handleThumbClick(price.id)" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ price.variant }}
</p>
<p class="text-xs tabular-nums text-muted-foreground">
Stok: {{ price.stock_formatted }}
</p>
</div>
<div v-if="getMaterialCartItem(price.id)" class="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="icon-sm" title="Kurangi Pemakaian"
@click.stop="emit('decrease-material-qty', price.id)">
<Minus class="size-3.5" />
</Button>
<span class="min-w-5 text-center text-xs font-semibold tabular-nums">
{{ getMaterialCartItem(price.id)!.material_usage }}
</span>
<Button type="button" variant="outline" size="icon-sm" title="Tambah ke Keranjang"
@click.stop="emit('add-material', rawMaterial, price)">
<Plus class="size-3.5" />
</Button>
<Button type="button" variant="default" size="icon-sm" title="Gunakan Kombinasi"
@click.stop="openCombination(rawMaterial, price)">
<Layers class="size-3.5" />
</Button>
</div>
<div v-else class="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="icon-sm" title="Tambah ke Keranjang"
@click.stop="emit('add-material', rawMaterial, price)">
<Plus class="size-3.5" />
</Button>
<Button type="button" variant="default" size="icon-sm" title="Gunakan Kombinasi"
@click.stop="openCombination(rawMaterial, price)">
<Layers class="size-3.5" />
</Button>
</div>
</div>
<div v-if="getMaterialCartItem(price.id)" class="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="icon-sm"
title="Kurangi Pemakaian"
@click.stop="emit('decrease-material-qty', price.id)">
<Minus class="size-3.5" />
</Button>
<span class="min-w-5 text-center text-xs font-semibold tabular-nums">
{{ getMaterialCartItem(price.id)!.material_usage }}
</span>
<Button type="button" variant="outline" size="icon-sm"
title="Tambah ke Keranjang"
@click.stop="emit('add-material', rawMaterial, price)">
<Plus class="size-3.5" />
</Button>
<Button type="button" variant="default" size="icon-sm"
title="Gunakan Kombinasi"
@click.stop="openCombination(rawMaterial, price)">
<Layers class="size-3.5" />
</Button>
</div>
<div v-else class="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="icon-sm"
title="Tambah ke Keranjang"
@click.stop="emit('add-material', rawMaterial, price)">
<Plus class="size-3.5" />
</Button>
<Button type="button" variant="default" size="icon-sm"
title="Gunakan Kombinasi"
@click.stop="openCombination(rawMaterial, price)">
<Layers class="size-3.5" />
</Button>
</div>
</div>
</PosCatalogCard>
</PosCatalogCard>
</template>
</div>
</div>
@ -175,4 +213,6 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
<CuttingPosCombinationDialog v-model:open="combinationOpen" :raw-material-catalog="rawMaterialCatalog"
:existing-cart-items="materialCart" :initial-variant="combinationInitialVariant"
@combination-created="emit('combination-created', $event)" />
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="previewUrls" :title="previewTitle" />
</template>

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { Check, Minus, Plus, Search } from '@lucide/vue';
import { ref } from 'vue';
import { computed, ref, watch } from 'vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -18,7 +19,7 @@ import type { CategoryOption } from '@/types/product';
import QuickCreateProductModal from './QuickCreateProductModal.vue';
import type { CuttingCatalogVariant } from './useCuttingPosCart';
defineProps<{
const props = defineProps<{
filteredProducts: CuttingProductCatalogItem[];
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
isCreateMode: boolean;
@ -26,6 +27,47 @@ defineProps<{
materialCart: CuttingMaterialCartItem[];
}>();
const previewOpen = ref(false);
const previewUrl = ref<string | null>(null);
const previewTitle = ref<string>('');
const allVariantImages = computed(() => {
const list: { variantId: number; title: string; url: string }[] = [];
props.filteredProducts.forEach((product) => {
product.variants.forEach((variant) => {
if (variant.images && variant.images.length > 0) {
list.push({
variantId: variant.id,
title: `${product.name} - ${variant.name}`,
url: variant.images[0].url,
});
}
});
});
return list;
});
const previewUrls = computed(() => allVariantImages.value.map((item) => item.url));
function handleThumbClick(variantId: number) {
const idx = allVariantImages.value.findIndex((item) => item.variantId === variantId);
if (idx !== -1) {
previewUrl.value = allVariantImages.value[idx].url;
previewTitle.value = allVariantImages.value[idx].title;
previewOpen.value = true;
}
}
watch(previewUrl, (newUrl) => {
const matched = allVariantImages.value.find((item) => item.url === newUrl);
if (matched) {
previewTitle.value = matched.title;
}
});
const productSearch = defineModel<string>('productSearch', { required: true });
const emit = defineEmits<{
@ -77,7 +119,8 @@ const quickCreateOpen = ref(false);
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
: '',
]" @click="!getResultCartItem(variant.id) && emit('add-result', product, variant)">
<PosCatalogVariantThumb :items="variant.images" />
<PosCatalogVariantThumb :items="variant.images" custom-preview
@click-thumb="handleThumbClick(variant.id)" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ variant.name }}
@ -124,4 +167,6 @@ const quickCreateOpen = ref(false);
<QuickCreateProductModal v-model:open="quickCreateOpen" :categories="categories" :selected-materials="materialCart"
@created="emit('product-created', $event)" />
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="previewUrls" :title="previewTitle" />
</template>

View File

@ -265,8 +265,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
{{ material.variant }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="material.images ?? []
" :max-visible="1" />
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" :title="`${group.rawMaterialName} - ${material.variant}`" />
</TableCell>
<TableCell class="tabular-nums">
{{
@ -323,9 +322,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
{{ result.product_variant?.name }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="result.product_variant
?.images ?? []
" :max-visible="1" />
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" :title="`${group.productName} - ${result.product_variant?.name}`" />
</TableCell>
<TableCell class="tabular-nums">
{{ result.cutting_result }} pcs

View File

@ -101,7 +101,7 @@ const emit = defineEmits<{
]"
@click="getVariantPrice(variant) && !getCartItem(variant.id) && emit('add-to-cart', product, variant)"
>
<PosCatalogVariantThumb :items="variant.images" />
<PosCatalogVariantThumb :items="variant.images" :title="`${product.name} - ${variant.name}`" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ variant.name }}

View File

@ -91,7 +91,7 @@ const emit = defineEmits<{
]"
@click="!getCartItem(price.id) && emit('add-to-cart', rawMaterial, price)"
>
<PosCatalogVariantThumb :items="price.images" />
<PosCatalogVariantThumb :items="price.images" :title="`${rawMaterial.name} - ${price.variant}`" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ price.variant }}

View File

@ -148,7 +148,7 @@ function openVerificationDetail(requestId: number | undefined) {
{{ variant.name }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" />
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" :title="`${product.name} - ${variant.name}`" />
</TableCell>
<TableCell class="tabular-nums">
{{ variant.stock_formatted }}

View File

@ -130,7 +130,7 @@ function openVerificationDetail(requestId: number | undefined) {
{{ price.variant }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1" />
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1" :title="`${material.name} - ${price.variant}`" />
</TableCell>
<TableCell class="tabular-nums">
{{ price.stock_formatted }}