store/resources/js/components/media/MediaImageUpload.vue

158 lines
5.2 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 { MediaItem, MediaUploadState } from '@/types/media';
const props = withDefaults(
defineProps<{
label?: string;
description?: string;
maxFiles?: number;
required?: boolean;
existing?: MediaItem[];
errors?: string[];
id?: string;
}>(),
{
label: 'Foto',
maxFiles: 1,
required: false,
existing: () => [],
errors: () => [],
id: 'media-upload',
},
);
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);
if (props.maxFiles === 1) {
previewUrls.value.forEach((url) => URL.revokeObjectURL(url));
previewUrls.value = [];
state.value.newFiles = [];
state.value.existing.forEach((item) => {
if (!state.value.removeIds.includes(item.id)) {
state.value.removeIds = [...state.value.removeIds, item.id];
}
});
}
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 sebagai
thumbnail.
</FieldDescription>
<FieldDescription v-else>
Gambar Pertama akan dijadikan sebagai thumbnail.
</FieldDescription>
<div 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>
<label v-if="canAddMore" :for="id"
class="flex size-14 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed text-muted-foreground hover:bg-muted/30">
<ImagePlus class="size-4" />
<span class="text-[10px]">Tambah</span>
<Input :id="id" type="file" accept="image/*" :multiple="maxFiles > 1" class="sr-only"
@change="onFileChange" />
</label>
</div>
<FieldError :errors="errors" />
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
</Field>
</template>