feat: implement client-side image compression in MediaDropzone component to optimize file uploads
This commit is contained in:
parent
7c307b991e
commit
fff6b147be
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import DropZone from 'dropzone-vue';
|
||||||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
@ -8,10 +9,74 @@ import {
|
|||||||
} from '@/components/ui/field';
|
} from '@/components/ui/field';
|
||||||
import type { MediaUploadState } from '@/types/media';
|
import type { MediaUploadState } from '@/types/media';
|
||||||
import { createMediaUploadState } from '@/types/media';
|
import { createMediaUploadState } from '@/types/media';
|
||||||
import DropZone from 'dropzone-vue';
|
|
||||||
import 'dropzone-vue/dist/dropzone-vue.common.css';
|
import 'dropzone-vue/dist/dropzone-vue.common.css';
|
||||||
import { computed, ref, watch } from 'vue';
|
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 (0–1)
|
||||||
|
|
||||||
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id: string;
|
id: string;
|
||||||
@ -80,11 +145,14 @@ function onAddedFile(item: { id: string; file: File }) {
|
|||||||
state.value.newFiles = [];
|
state.value.newFiles = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
state.value.newFiles.push(item.file);
|
// Compress image asynchronously, then add to state
|
||||||
filePreviews.value.push({
|
compressImage(item.file).then((compressed) => {
|
||||||
id: item.id,
|
state.value.newFiles.push(compressed);
|
||||||
file: item.file,
|
filePreviews.value.push({
|
||||||
objectUrl: URL.createObjectURL(item.file),
|
id: item.id,
|
||||||
|
file: compressed,
|
||||||
|
objectUrl: URL.createObjectURL(compressed),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,49 +261,25 @@ watch(
|
|||||||
|
|
||||||
<div class="dropzone-wrapper">
|
<div class="dropzone-wrapper">
|
||||||
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
|
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
|
||||||
<div
|
<div :id="`dz-container-${id}`" :class="[
|
||||||
:id="`dz-container-${id}`"
|
'dz-form-host',
|
||||||
:class="[
|
{ 'dz-form-hidden': allPreviews.length > 0 },
|
||||||
'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
|
:dropzone-message-class-name="`dz-message-${id}`" @added-file="onAddedFile"
|
||||||
:id="id"
|
@removed-file="onRemovedFile">
|
||||||
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>
|
<template #message>
|
||||||
<div class="dz-placeholder">
|
<div class="dz-placeholder">
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"
|
||||||
width="22"
|
stroke-linejoin="round">
|
||||||
height="22"
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
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" />
|
<polyline points="17 8 12 3 7 8" />
|
||||||
<line x1="12" y1="3" x2="12" y2="15" />
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="dz-placeholder-primary"
|
<span class="dz-placeholder-primary">Klik atau seret file ke sini</span>
|
||||||
>Klik atau seret file ke sini</span
|
|
||||||
>
|
|
||||||
<span class="dz-placeholder-secondary">
|
<span class="dz-placeholder-secondary">
|
||||||
{{ acceptedFiles.join(', ') }} — Maks.
|
{{ acceptedFiles.join(', ') }} — Maks.
|
||||||
{{ maxFiles }} file,
|
{{ maxFiles }} file,
|
||||||
@ -247,29 +291,14 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
|
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
|
||||||
<div
|
<div v-if="allPreviews.length > 0" class="dz-overlay" @click.stop="triggerDropzonePicker($event)">
|
||||||
v-if="allPreviews.length > 0"
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"
|
||||||
class="dz-overlay"
|
stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@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" />
|
<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" />
|
<polyline points="17 8 12 3 7 8" />
|
||||||
<line x1="12" y1="3" x2="12" y2="15" />
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="dz-placeholder-primary"
|
<span class="dz-placeholder-primary">Klik atau seret file ke sini</span>
|
||||||
>Klik atau seret file ke sini</span
|
|
||||||
>
|
|
||||||
<span class="dz-placeholder-secondary">
|
<span class="dz-placeholder-secondary">
|
||||||
{{ acceptedFiles.join(', ') }} — Maks.
|
{{ acceptedFiles.join(', ') }} — Maks.
|
||||||
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
|
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
|
||||||
@ -278,43 +307,19 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Preview grid (existing + new) -->
|
<!-- Preview grid (existing + new) -->
|
||||||
<div
|
<div v-if="allPreviews.length > 0" :class="[
|
||||||
v-if="allPreviews.length > 0"
|
'preview-grid',
|
||||||
:class="[
|
{ 'preview-grid--single': maxFiles === 1 },
|
||||||
'preview-grid',
|
]">
|
||||||
{ 'preview-grid--single': maxFiles === 1 },
|
<div v-for="entry in allPreviews" :key="entry.key" class="preview-card">
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="entry in allPreviews"
|
|
||||||
:key="entry.key"
|
|
||||||
class="preview-card"
|
|
||||||
>
|
|
||||||
<!-- Thumbnail -->
|
<!-- Thumbnail -->
|
||||||
<div
|
<div class="preview-thumb" @click="openPreview(entry.previewSrc)">
|
||||||
class="preview-thumb"
|
<img v-if="entry.isImage" :src="entry.thumbSrc" :alt="entry.name" />
|
||||||
@click="openPreview(entry.previewSrc)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="entry.isImage"
|
|
||||||
:src="entry.thumbSrc"
|
|
||||||
:alt="entry.name"
|
|
||||||
/>
|
|
||||||
<div v-else class="preview-thumb-icon">
|
<div v-else class="preview-thumb-icon">
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||||
width="32"
|
stroke-linejoin="round">
|
||||||
height="32"
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
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" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@ -328,23 +333,10 @@ watch(
|
|||||||
</p>
|
</p>
|
||||||
<p class="preview-size">{{ entry.sizeLabel }}</p>
|
<p class="preview-size">{{ entry.sizeLabel }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button type="button" class="preview-remove" title="Hapus" @click="entry.onRemove()">
|
||||||
type="button"
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||||
class="preview-remove"
|
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||||
title="Hapus"
|
stroke-linejoin="round">
|
||||||
@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="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -355,11 +347,7 @@ watch(
|
|||||||
|
|
||||||
<FieldError :errors="errors" />
|
<FieldError :errors="errors" />
|
||||||
|
|
||||||
<MediaPreviewDialog
|
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
|
||||||
v-model:open="previewOpen"
|
|
||||||
:url="previewUrl"
|
|
||||||
:title="label"
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user