161 lines
5.1 KiB
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>
|