store/resources/js/components/ui/image-upload-field/MultipleImageUploadField.vue

161 lines
5.1 KiB
Vue

<script setup lang="ts">
import { ImagePlus, X } from '@lucide/vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
import { Button } from '@/components/ui/button';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
createMediaUploadState,
mediaCount,
} from '@/types/media';
import type { MediaUploadState } from '@/types/media';
const props = withDefaults(
defineProps<{
id: string;
label: string;
description?: string;
maxFiles?: number;
required?: boolean;
errors?: string[];
}>(),
{
maxFiles: 5,
required: false,
errors: () => [],
},
);
const state = defineModel<MediaUploadState>({
default: () => createMediaUploadState(),
});
const previewUrls = ref<string[]>([]);
const previewOpen = ref(false);
const previewUrl = ref<string | null>(null);
const remainingSlots = computed(() => props.maxFiles - mediaCount(state.value));
const canAddMore = computed(() => remainingSlots.value > 0);
const visibleExisting = computed(() =>
state.value.existing.filter((item) => !state.value.removeIds.includes(item.id)),
);
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
if (files.length === 0) {
return;
}
const allowed = files.slice(0, remainingSlots.value);
allowed.forEach((file) => {
state.value.newFiles.push(file);
previewUrls.value.push(URL.createObjectURL(file));
});
input.value = '';
}
function removeExisting(id: number) {
if (!state.value.removeIds.includes(id)) {
state.value.removeIds = [...state.value.removeIds, id];
}
}
function removeNew(index: number) {
const url = previewUrls.value[index];
if (url) {
URL.revokeObjectURL(url);
}
previewUrls.value.splice(index, 1);
state.value.newFiles.splice(index, 1);
}
function openPreview(url: string) {
previewUrl.value = url;
previewOpen.value = true;
}
onBeforeUnmount(() => {
previewUrls.value.forEach((url) => URL.revokeObjectURL(url));
});
</script>
<template>
<Field>
<FieldLabel :for="id" :required="required">
{{ label }}
</FieldLabel>
<FieldDescription v-if="description">
{{ description }}
</FieldDescription>
<FieldDescription v-else-if="maxFiles > 1">
Maks. {{ maxFiles }} gambar. Gambar pertama akan dijadikan thumbnail.
</FieldDescription>
<div class="space-y-3">
<div v-if="visibleExisting.length > 0 || previewUrls.length > 0" class="flex flex-wrap items-center gap-2">
<button
v-for="item in visibleExisting"
:key="`existing-${item.id}`"
type="button"
class="group relative size-14 overflow-hidden rounded-md border bg-muted/30"
@click="openPreview(item.url)"
>
<img :src="item.thumb_url" :alt="label" class="size-full object-cover">
<Button
type="button"
variant="secondary"
size="icon"
class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100"
@click.stop="removeExisting(item.id)"
>
<X class="size-3" />
</Button>
</button>
<button
v-for="(url, index) in previewUrls"
:key="`new-${index}`"
type="button"
class="group relative size-14 overflow-hidden rounded-md border bg-muted/30"
@click="openPreview(url)"
>
<img :src="url" :alt="label" class="size-full object-cover">
<Button
type="button"
variant="secondary"
size="icon"
class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100"
@click.stop="removeNew(index)"
>
<X class="size-3" />
</Button>
</button>
</div>
<div v-if="canAddMore" class="flex items-center gap-2">
<Input
:id="id"
type="file"
accept="image/*"
:multiple="maxFiles > 1"
class="max-w-md cursor-pointer"
@change="onFileChange"
/>
<ImagePlus class="text-muted-foreground size-5 shrink-0" />
</div>
</div>
<FieldError :errors="errors" />
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
</Field>
</template>