157 lines
5.2 KiB
Vue
157 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. Klik thumbnail untuk memperbesar.
|
|
</FieldDescription>
|
|
<FieldDescription v-else>
|
|
Klik thumbnail untuk memperbesar.
|
|
</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>
|