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

806 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import DropZone from 'dropzone-vue';
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import { uploadFileAndGetKey } from '@/lib/s3-upload';
import type { MediaUploadState } from '@/types/media';
import { createMediaUploadState } from '@/types/media';
import 'dropzone-vue/dist/dropzone-vue.common.css';
import { computed, ref, watch } from 'vue';
// ─── Client-side image compression ───────────────────────────────────────────
const COMPRESS_MAX_PX = 1200; // max width/height in pixels
const COMPRESS_QUALITY = 0.80; // JPEG quality (01)
async function compressImage(file: File): Promise<File> {
// Only compress raster images; skip SVG, GIF, etc.
if (!file.type.startsWith('image/') || file.type === 'image/svg+xml' || file.type === 'image/gif') {
return file;
}
return new Promise((resolve) => {
const img = new Image();
const originalUrl = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(originalUrl);
let { width, height } = img;
// Scale down proportionally if image exceeds max dimension
if (width > COMPRESS_MAX_PX || height > COMPRESS_MAX_PX) {
if (width >= height) {
height = Math.round((height / width) * COMPRESS_MAX_PX);
width = COMPRESS_MAX_PX;
} else {
width = Math.round((width / height) * COMPRESS_MAX_PX);
height = COMPRESS_MAX_PX;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, width, height);
// Output as JPEG for photos regardless of original format (except PNG transparency)
const outputMime = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
canvas.toBlob(
(blob) => {
if (!blob || blob.size >= file.size) {
// Compression made it bigger or failed — keep original
resolve(file);
return;
}
resolve(new File([blob], file.name, { type: outputMime, lastModified: Date.now() }));
},
outputMime,
COMPRESS_QUALITY,
);
};
img.onerror = () => {
URL.revokeObjectURL(originalUrl);
resolve(file); // fallback to original
};
img.src = originalUrl;
});
}
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(),
});
// ─── Upload progress tracking ────────────────────────────────────────────────
const uploadProgress = ref<Record<string, number>>({});
const uploadErrors = ref<Record<string, string>>({});
// ─── 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.newFileS3Keys = [];
}
// Compress image asynchronously, then upload to S3
compressImage(item.file).then((compressed) => {
const fileIndex = state.value.newFiles.length;
state.value.newFiles.push(compressed);
filePreviews.value.push({
id: item.id,
file: compressed,
objectUrl: URL.createObjectURL(compressed),
});
// Upload to S3 in background
uploadProgress.value[item.id] = 0;
delete uploadErrors.value[item.id];
state.value.pendingUploads++;
uploadFileAndGetKey(compressed, (percent) => {
uploadProgress.value[item.id] = percent;
}).then((s3Key) => {
state.value.newFileS3Keys[fileIndex] = s3Key;
delete uploadProgress.value[item.id];
state.value.pendingUploads--;
}).catch((error: Error) => {
uploadErrors.value[item.id] = error.message;
delete uploadProgress.value[item.id];
state.value.pendingUploads--;
// Remove the file from state on upload failure
const idx = state.value.newFiles.indexOf(compressed);
if (idx !== -1) {
state.value.newFiles.splice(idx, 1);
state.value.newFileS3Keys.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);
}
dropzoneRef.value?.removeFile(item.id);
});
});
}
function onRemovedFile(item: { id: string; file: File }) {
const idx = state.value.newFiles.indexOf(item.file);
if (idx !== -1) {
state.value.newFiles.splice(idx, 1);
state.value.newFileS3Keys.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);
}
delete uploadProgress.value[item.id];
delete uploadErrors.value[item.id];
}
// ─── 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;
uploadPercent: number | null;
uploadError: string | null;
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',
uploadPercent: null,
uploadError: null,
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),
uploadPercent: uploadProgress.value[p.id] ?? null,
uploadError: uploadErrors.value[p.id] ?? null,
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>
<!-- Upload progress overlay -->
<div v-if="entry.uploadPercent !== null" class="upload-progress-overlay">
<div class="upload-progress-bar">
<div class="upload-progress-fill" :style="{ width: `${entry.uploadPercent}%` }" />
</div>
<span class="upload-progress-text">{{ entry.uploadPercent }}%</span>
</div>
<!-- Upload error overlay -->
<div v-if="entry.uploadError" class="upload-error-overlay">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</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.uploadError ? 'Gagal' : 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: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.5rem;
padding: 1rem;
width: 100%;
min-width: 0;
cursor: pointer;
overflow: hidden;
}
@media (min-width: 640px) {
.dz-placeholder {
flex-direction: row;
align-items: center;
text-align: left;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
}
/* === Overlay (shown when previews exist) === */
.dz-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.5rem;
padding: 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;
}
@media (min-width: 640px) {
.dz-overlay {
flex-direction: row;
align-items: center;
text-align: left;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
}
.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);
flex-shrink: 0;
}
@media (min-width: 640px) {
.dz-placeholder-primary {
white-space: nowrap;
}
}
.dz-placeholder-secondary {
font-size: 0.75rem;
color: var(--muted-foreground);
min-width: 0;
flex: 1;
}
@media (min-width: 640px) {
.dz-placeholder-secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
/* ---- Preview grid — multiple mode (default) ---- */
.preview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 0.75rem;
}
@media (min-width: 400px) {
.preview-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (min-width: 560px) {
.preview-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media (min-width: 720px) {
.preview-grid {
grid-template-columns: repeat(6, minmax(0, 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;
position: relative;
}
.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;
}
/* Upload progress overlay */
.upload-progress-overlay {
position: absolute;
inset: 0;
background: color-mix(in oklch, var(--background) 80%, transparent);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.5rem;
}
.upload-progress-bar {
width: 80%;
height: 4px;
background: var(--muted);
border-radius: 9999px;
overflow: hidden;
}
.upload-progress-fill {
height: 100%;
background: var(--primary);
border-radius: 9999px;
transition: width 0.2s;
}
.upload-progress-text {
font-size: 0.65rem;
font-weight: 600;
color: var(--foreground);
}
/* Upload error overlay */
.upload-error-overlay {
position: absolute;
inset: 0;
background: color-mix(in oklch, var(--destructive) 20%, transparent);
display: flex;
align-items: center;
justify-content: center;
color: var(--destructive);
}
/* 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>