668 lines
20 KiB
Vue
668 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
|
import {
|
|
Field,
|
|
FieldDescription,
|
|
FieldError,
|
|
FieldLabel,
|
|
} from '@/components/ui/field';
|
|
import type { MediaUploadState } from '@/types/media';
|
|
import { createMediaUploadState } from '@/types/media';
|
|
import DropZone from 'dropzone-vue';
|
|
import 'dropzone-vue/dist/dropzone-vue.common.css';
|
|
import { computed, ref, watch } from 'vue';
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
maxFiles?: number;
|
|
maxFileSize?: number;
|
|
required?: boolean;
|
|
errors?: string[];
|
|
acceptedFiles?: string[];
|
|
}>(),
|
|
{
|
|
maxFiles: 5,
|
|
maxFileSize: 5_000_000,
|
|
required: false,
|
|
errors: () => [],
|
|
acceptedFiles: () => ['image'],
|
|
},
|
|
);
|
|
|
|
const state = defineModel<MediaUploadState>({
|
|
default: () => createMediaUploadState(),
|
|
});
|
|
|
|
// ─── Dialog ──────────────────────────────────────────────────────────────────
|
|
const previewOpen = ref(false);
|
|
const previewUrl = ref<string | null>(null);
|
|
|
|
function openPreview(url: string) {
|
|
previewUrl.value = url;
|
|
previewOpen.value = true;
|
|
}
|
|
|
|
// ─── New file tracking (dropzone) ────────────────────────────────────────────
|
|
type FilePreview = { id: string; file: File; objectUrl: string };
|
|
|
|
const filePreviews = ref<FilePreview[]>([]);
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) {
|
|
return `${bytes} B`;
|
|
}
|
|
|
|
if (bytes < 1024 * 1024) {
|
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
}
|
|
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function onAddedFile(item: { id: string; file: File }) {
|
|
if (props.maxFiles === 1) {
|
|
state.value.existing.forEach((existingItem) => {
|
|
if (!state.value.removeIds.includes(existingItem.id)) {
|
|
state.value.removeIds.push(existingItem.id);
|
|
}
|
|
});
|
|
state.value.existing = [];
|
|
|
|
const previewsCopy = [...filePreviews.value];
|
|
previewsCopy.forEach((p) => {
|
|
dropzoneRef.value?.removeFile(p.id);
|
|
});
|
|
filePreviews.value.forEach((p) => URL.revokeObjectURL(p.objectUrl));
|
|
filePreviews.value = [];
|
|
state.value.newFiles = [];
|
|
}
|
|
|
|
state.value.newFiles.push(item.file);
|
|
filePreviews.value.push({
|
|
id: item.id,
|
|
file: item.file,
|
|
objectUrl: URL.createObjectURL(item.file),
|
|
});
|
|
}
|
|
|
|
function onRemovedFile(item: { id: string; file: File }) {
|
|
const idx = state.value.newFiles.indexOf(item.file);
|
|
|
|
if (idx !== -1) {
|
|
state.value.newFiles.splice(idx, 1);
|
|
}
|
|
|
|
const pi = filePreviews.value.findIndex((p) => p.id === item.id);
|
|
|
|
if (pi !== -1) {
|
|
URL.revokeObjectURL(filePreviews.value[pi].objectUrl);
|
|
filePreviews.value.splice(pi, 1);
|
|
}
|
|
}
|
|
|
|
// ─── Unified normalized entries ───────────────────────────────────────────────
|
|
type NormalizedEntry = {
|
|
key: string;
|
|
thumbSrc: string; // src for thumbnail img
|
|
isImage: boolean; // false → show file icon
|
|
previewSrc: string; // src for fullscreen preview
|
|
name: string;
|
|
sizeLabel: string;
|
|
onRemove: () => void;
|
|
};
|
|
|
|
const dropzoneRef = ref<InstanceType<typeof DropZone> | null>(null);
|
|
|
|
const allPreviews = computed<NormalizedEntry[]>(() => {
|
|
const existing: NormalizedEntry[] = state.value.existing.map((item) => ({
|
|
key: `existing-${item.id}`,
|
|
thumbSrc: item.url, // thumb_url terlalu kecil → pixelated; pakai url full
|
|
isImage: true,
|
|
previewSrc: item.url,
|
|
name: item.url.split('/').pop() ?? `image-${item.id}`,
|
|
sizeLabel: 'Tersimpan',
|
|
onRemove: () => {
|
|
const idx = state.value.existing.findIndex((e) => e.id === item.id);
|
|
|
|
if (idx !== -1) {
|
|
state.value.existing.splice(idx, 1);
|
|
|
|
if (!state.value.removeIds.includes(item.id)) {
|
|
state.value.removeIds.push(item.id);
|
|
}
|
|
}
|
|
},
|
|
}));
|
|
|
|
const newFiles: NormalizedEntry[] = filePreviews.value.map((p) => ({
|
|
key: `new-${p.id}`,
|
|
thumbSrc: p.file.type.startsWith('image/') ? p.objectUrl : '',
|
|
isImage: p.file.type.startsWith('image/'),
|
|
previewSrc: p.objectUrl,
|
|
name: p.file.name,
|
|
sizeLabel: formatBytes(p.file.size),
|
|
onRemove: () => dropzoneRef.value?.removeFile(p.id),
|
|
}));
|
|
|
|
return [...existing, ...newFiles];
|
|
});
|
|
|
|
// ─── Dropzone picker trigger ──────────────────────────────────────────────────
|
|
function triggerDropzonePicker(event?: MouseEvent) {
|
|
event?.stopPropagation();
|
|
|
|
const container = document.getElementById(`dz-container-${props.id}`);
|
|
const input =
|
|
container?.querySelector<HTMLInputElement>('input[type="file"]');
|
|
|
|
input?.click();
|
|
}
|
|
|
|
watch(
|
|
() => state.value.newFiles,
|
|
(newFiles) => {
|
|
if (newFiles.length === 0 && filePreviews.value.length > 0) {
|
|
const previewsCopy = [...filePreviews.value];
|
|
previewsCopy.forEach((p) => {
|
|
dropzoneRef.value?.removeFile(p.id);
|
|
});
|
|
filePreviews.value.forEach((p) => URL.revokeObjectURL(p.objectUrl));
|
|
filePreviews.value = [];
|
|
}
|
|
},
|
|
{ deep: true },
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="media-dropzone-root">
|
|
<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="dropzone-wrapper">
|
|
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
|
|
<div
|
|
:id="`dz-container-${id}`"
|
|
:class="[
|
|
'dz-form-host',
|
|
{ 'dz-form-hidden': allPreviews.length > 0 },
|
|
]"
|
|
>
|
|
<DropZone
|
|
:id="id"
|
|
ref="dropzoneRef"
|
|
:max-files="maxFiles"
|
|
:max-file-size="maxFileSize"
|
|
:accepted-files="acceptedFiles"
|
|
:upload-on-drop="false"
|
|
:clickable="true"
|
|
:hidden-input-container="`#dz-container-${id}`"
|
|
dropzone-class-name="dz-box"
|
|
:dropzone-message-class-name="`dz-message-${id}`"
|
|
@added-file="onAddedFile"
|
|
@removed-file="onRemovedFile"
|
|
>
|
|
<template #message>
|
|
<div class="dz-placeholder">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="22"
|
|
height="22"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.75"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path
|
|
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
|
|
/>
|
|
<polyline points="17 8 12 3 7 8" />
|
|
<line x1="12" y1="3" x2="12" y2="15" />
|
|
</svg>
|
|
<span class="dz-placeholder-primary"
|
|
>Klik atau seret file ke sini</span
|
|
>
|
|
<span class="dz-placeholder-secondary">
|
|
{{ acceptedFiles.join(', ') }} — Maks.
|
|
{{ maxFiles }} file,
|
|
{{ formatBytes(maxFileSize) }}/file
|
|
</span>
|
|
</div>
|
|
</template>
|
|
</DropZone>
|
|
</div>
|
|
|
|
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
|
|
<div
|
|
v-if="allPreviews.length > 0"
|
|
class="dz-overlay"
|
|
@click.stop="triggerDropzonePicker($event)"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="22"
|
|
height="22"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.75"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<polyline points="17 8 12 3 7 8" />
|
|
<line x1="12" y1="3" x2="12" y2="15" />
|
|
</svg>
|
|
<span class="dz-placeholder-primary"
|
|
>Klik atau seret file ke sini</span
|
|
>
|
|
<span class="dz-placeholder-secondary">
|
|
{{ acceptedFiles.join(', ') }} — Maks.
|
|
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Preview grid (existing + new) -->
|
|
<div
|
|
v-if="allPreviews.length > 0"
|
|
:class="[
|
|
'preview-grid',
|
|
{ 'preview-grid--single': maxFiles === 1 },
|
|
]"
|
|
>
|
|
<div
|
|
v-for="entry in allPreviews"
|
|
:key="entry.key"
|
|
class="preview-card"
|
|
>
|
|
<!-- Thumbnail -->
|
|
<div
|
|
class="preview-thumb"
|
|
@click="openPreview(entry.previewSrc)"
|
|
>
|
|
<img
|
|
v-if="entry.isImage"
|
|
:src="entry.thumbSrc"
|
|
:alt="entry.name"
|
|
/>
|
|
<div v-else class="preview-thumb-icon">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path
|
|
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
|
/>
|
|
<polyline points="14 2 14 8 20 8" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info + Remove -->
|
|
<div class="preview-footer">
|
|
<div class="preview-info">
|
|
<p class="preview-name" :title="entry.name">
|
|
{{ entry.name }}
|
|
</p>
|
|
<p class="preview-size">{{ entry.sizeLabel }}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="preview-remove"
|
|
title="Hapus"
|
|
@click="entry.onRemove()"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<FieldError :errors="errors" />
|
|
|
|
<MediaPreviewDialog
|
|
v-model:open="previewOpen"
|
|
:url="previewUrl"
|
|
:title="label"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* === Root containment: prevents MediaDropzone from expanding parent grid/flex === */
|
|
.media-dropzone-root {
|
|
width: 100%;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* === Wrapper === */
|
|
.dropzone-wrapper {
|
|
position: relative;
|
|
width: 100%;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
box-sizing: border-box;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Host div that wraps the dropzone <form> */
|
|
.dz-form-host {
|
|
display: block;
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* When previews exist: collapse form out of view (keep in DOM so dropzone JS stays alive) */
|
|
.dz-form-hidden {
|
|
position: absolute;
|
|
visibility: hidden;
|
|
pointer-events: none;
|
|
width: 100%;
|
|
height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* === Dropzone box (the <form> element) === */
|
|
/* Target both our custom class and dropzone-vue's own .dropzone class */
|
|
.dropzone-wrapper :deep(.dz-box),
|
|
.dropzone-wrapper :deep(.dropzone) {
|
|
width: 100% !important;
|
|
max-width: 100% !important;
|
|
min-width: 0 !important;
|
|
box-sizing: border-box !important;
|
|
}
|
|
|
|
.dropzone-wrapper :deep(.dz-box) {
|
|
border: 2px dashed var(--border);
|
|
border-radius: var(--radius-lg);
|
|
background: color-mix(in oklch, var(--muted) 50%, transparent);
|
|
cursor: pointer;
|
|
transition:
|
|
border-color 0.2s,
|
|
background-color 0.2s;
|
|
display: flex !important;
|
|
flex-direction: column !important;
|
|
min-height: 0 !important;
|
|
}
|
|
|
|
.dropzone-wrapper :deep(.dz-box:hover) {
|
|
border-color: var(--primary);
|
|
background: color-mix(in oklch, var(--muted) 80%, transparent);
|
|
}
|
|
|
|
/* Hide built-in item previews */
|
|
.dropzone-wrapper :deep(.dropzone__item--style),
|
|
.dropzone-wrapper :deep(.dropzone__item),
|
|
.dropzone-wrapper :deep(.dropzone__container),
|
|
.dropzone-wrapper :deep(.dropzone__items) {
|
|
display: none !important;
|
|
}
|
|
|
|
/* Message slot wrapper */
|
|
.dropzone-wrapper :deep(.dropzone__message),
|
|
.dropzone-wrapper :deep([class*='dz-message-']) {
|
|
flex: 1 !important;
|
|
display: flex !important;
|
|
padding: 0 !important;
|
|
margin: 0 !important;
|
|
}
|
|
|
|
/* === Placeholder inside slot === */
|
|
.dz-placeholder {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem 1rem;
|
|
width: 100%;
|
|
min-width: 0;
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* === Overlay (shown when previews exist) === */
|
|
.dz-overlay {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem 1rem;
|
|
width: 100%;
|
|
cursor: pointer;
|
|
border: 2px dashed var(--border);
|
|
border-radius: var(--radius-lg);
|
|
background: color-mix(in oklch, var(--muted) 50%, transparent);
|
|
transition:
|
|
border-color 0.2s,
|
|
background-color 0.2s;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.dz-overlay:hover {
|
|
border-color: var(--primary);
|
|
background: color-mix(in oklch, var(--muted) 80%, transparent);
|
|
}
|
|
|
|
.dz-placeholder svg,
|
|
.dz-overlay svg {
|
|
flex-shrink: 0;
|
|
opacity: 0.7;
|
|
color: var(--muted-foreground);
|
|
}
|
|
|
|
.dz-placeholder-primary {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: var(--foreground);
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.dz-placeholder-secondary {
|
|
font-size: 0.75rem;
|
|
color: var(--muted-foreground);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
/* ---- Preview grid — multiple mode (default) ---- */
|
|
.preview-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 0.5rem;
|
|
margin-top: 0.75rem;
|
|
}
|
|
|
|
@media (min-width: 400px) {
|
|
.preview-grid {
|
|
grid-template-columns: repeat(4, 1fr);
|
|
}
|
|
}
|
|
|
|
@media (min-width: 560px) {
|
|
.preview-grid {
|
|
grid-template-columns: repeat(5, 1fr);
|
|
}
|
|
}
|
|
|
|
@media (min-width: 720px) {
|
|
.preview-grid {
|
|
grid-template-columns: repeat(6, 1fr);
|
|
}
|
|
}
|
|
|
|
.preview-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
background: color-mix(in oklch, var(--card) 90%, transparent);
|
|
overflow: hidden;
|
|
transition: box-shadow 0.15s;
|
|
}
|
|
|
|
.preview-card:hover {
|
|
box-shadow: 0 2px 8px color-mix(in oklch, var(--foreground) 8%, transparent);
|
|
}
|
|
|
|
/* ---- Preview grid — single mode (maxFiles === 1) ---- */
|
|
.preview-grid--single {
|
|
grid-template-columns: 1fr !important;
|
|
}
|
|
|
|
/* Single: card is horizontal — thumbnail left, info right */
|
|
.preview-grid--single .preview-card {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
}
|
|
|
|
.preview-grid--single .preview-thumb {
|
|
width: 56px;
|
|
height: 56px;
|
|
aspect-ratio: unset;
|
|
flex-shrink: 0;
|
|
border-radius: 0;
|
|
}
|
|
|
|
.preview-grid--single .preview-footer {
|
|
flex: 1;
|
|
border-top: none;
|
|
border-left: 1px solid var(--border);
|
|
padding: 0.4rem 0.6rem;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* Thumbnail: square, full-width */
|
|
.preview-thumb {
|
|
width: 100%;
|
|
aspect-ratio: 1 / 1;
|
|
overflow: hidden;
|
|
background: var(--muted);
|
|
cursor: zoom-in;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.preview-thumb img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.preview-thumb:hover img {
|
|
transform: scale(1.04);
|
|
}
|
|
|
|
.preview-thumb-icon {
|
|
color: var(--muted-foreground);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Footer row: info + remove button */
|
|
.preview-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.2rem;
|
|
padding: 0.3rem 0.35rem 0.3rem 0.45rem;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
/* Info */
|
|
.preview-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.1rem;
|
|
}
|
|
|
|
.preview-name {
|
|
font-size: 0.7rem;
|
|
font-weight: 500;
|
|
color: var(--foreground);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
margin: 0;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.preview-size {
|
|
font-size: 0.65rem;
|
|
color: var(--muted-foreground);
|
|
margin: 0;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
/* Remove button */
|
|
.preview-remove {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--muted-foreground);
|
|
cursor: pointer;
|
|
transition:
|
|
background 0.15s,
|
|
color 0.15s;
|
|
padding: 0;
|
|
}
|
|
|
|
.preview-remove:hover {
|
|
background: color-mix(in oklch, var(--destructive) 15%, transparent);
|
|
color: var(--destructive);
|
|
}
|
|
</style>
|