store/resources/js/components/media/MediaDropzone.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(', ') }} &mdash; 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(', ') }} &mdash; 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>