refactor: improve MediaDropzone file handling and input triggering with container-based scoping
This commit is contained in:
parent
6219f5032d
commit
cce733e288
@ -28,6 +28,6 @@ public function registerMediaCollections(): void
|
|||||||
{
|
{
|
||||||
$this->addMediaCollection('hero_image')->singleFile();
|
$this->addMediaCollection('hero_image')->singleFile();
|
||||||
$this->addMediaCollection('about_image')->singleFile();
|
$this->addMediaCollection('about_image')->singleFile();
|
||||||
$this->addMediaCollection('gallery')->singleFile();
|
$this->addMediaCollection('gallery');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,7 +41,7 @@ public function updateHomepage(array $validated): void
|
|||||||
$this->mediaService->replaceSingleFile($configuration, $validated['about_image'], 'about_image', 'about-image');
|
$this->mediaService->replaceSingleFile($configuration, $validated['about_image'], 'about_image', 'about-image');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($validated['gallery_images'])) {
|
if (isset($validated['gallery_images']) || isset($validated['gallery_images_remove'])) {
|
||||||
$this->mediaService->syncCollection(
|
$this->mediaService->syncCollection(
|
||||||
$configuration,
|
$configuration,
|
||||||
'gallery',
|
'gallery',
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DropZone from 'dropzone-vue';
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
@ -10,9 +8,11 @@ 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';
|
||||||
|
|
||||||
withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -102,7 +102,7 @@ const dropzoneRef = ref<InstanceType<typeof DropZone> | null>(null);
|
|||||||
const allPreviews = computed<NormalizedEntry[]>(() => {
|
const allPreviews = computed<NormalizedEntry[]>(() => {
|
||||||
const existing: NormalizedEntry[] = state.value.existing.map((item) => ({
|
const existing: NormalizedEntry[] = state.value.existing.map((item) => ({
|
||||||
key: `existing-${item.id}`,
|
key: `existing-${item.id}`,
|
||||||
thumbSrc: item.url, // thumb_url terlalu kecil → pixelated; pakai url full
|
thumbSrc: item.url, // thumb_url terlalu kecil → pixelated; pakai url full
|
||||||
isImage: true,
|
isImage: true,
|
||||||
previewSrc: item.url,
|
previewSrc: item.url,
|
||||||
name: item.url.split('/').pop() ?? `image-${item.id}`,
|
name: item.url.split('/').pop() ?? `image-${item.id}`,
|
||||||
@ -134,35 +134,30 @@ const allPreviews = computed<NormalizedEntry[]>(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Dropzone picker trigger ──────────────────────────────────────────────────
|
// ─── Dropzone picker trigger ──────────────────────────────────────────────────
|
||||||
function triggerDropzonePicker() {
|
function triggerDropzonePicker(event?: MouseEvent) {
|
||||||
const form = dropzoneRef.value?.$el as HTMLFormElement | null;
|
event?.stopPropagation();
|
||||||
|
|
||||||
// 1. Try inside the form
|
const container = document.getElementById(`dz-container-${props.id}`);
|
||||||
let input =
|
const input =
|
||||||
form?.querySelector<HTMLInputElement>('input[type="file"]') ?? null;
|
container?.querySelector<HTMLInputElement>('input[type="file"]');
|
||||||
|
|
||||||
// 2. dropzone-vue appends hidden input to <body>
|
input?.click();
|
||||||
if (!input) {
|
|
||||||
const all = Array.from(
|
|
||||||
document.body.querySelectorAll<HTMLInputElement>(
|
|
||||||
'input[type="file"]',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
input =
|
|
||||||
all.find(
|
|
||||||
(el) =>
|
|
||||||
el.style.display === 'none' || el.hasAttribute('hidden'),
|
|
||||||
) ??
|
|
||||||
all[0] ??
|
|
||||||
null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input) {
|
|
||||||
input.click();
|
|
||||||
} else {
|
|
||||||
form?.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -181,24 +176,49 @@ function triggerDropzonePicker() {
|
|||||||
|
|
||||||
<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 :class="[
|
<div
|
||||||
'dz-form-host',
|
:id="`dz-container-${id}`"
|
||||||
{ 'dz-form-hidden': allPreviews.length > 0 },
|
:class="[
|
||||||
]">
|
'dz-form-host',
|
||||||
<DropZone ref="dropzoneRef" :max-files="maxFiles" :max-file-size="maxFileSize"
|
{ 'dz-form-hidden': allPreviews.length > 0 },
|
||||||
:accepted-files="acceptedFiles" :upload-on-drop="false" :clickable="true"
|
]"
|
||||||
dropzone-class-name="dz-box" dropzone-message-class-name="dz-message" @added-file="onAddedFile"
|
>
|
||||||
@removed-file="onRemovedFile">
|
<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>
|
<template #message>
|
||||||
<div class="dz-placeholder">
|
<div class="dz-placeholder">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24"
|
<svg
|
||||||
fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
stroke-linejoin="round">
|
width="22"
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
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" />
|
<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">Klik atau seret file ke sini</span>
|
<span class="dz-placeholder-primary"
|
||||||
|
>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,
|
||||||
@ -210,14 +230,29 @@ function triggerDropzonePicker() {
|
|||||||
</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 v-if="allPreviews.length > 0" class="dz-overlay" @click="triggerDropzonePicker">
|
<div
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"
|
v-if="allPreviews.length > 0"
|
||||||
stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
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" />
|
<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">Klik atau seret file ke sini</span>
|
<span class="dz-placeholder-primary"
|
||||||
|
>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
|
||||||
@ -226,16 +261,43 @@ function triggerDropzonePicker() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Preview grid (existing + new) -->
|
<!-- Preview grid (existing + new) -->
|
||||||
<div v-if="allPreviews.length > 0" :class="['preview-grid', { 'preview-grid--single': maxFiles === 1 }]">
|
<div
|
||||||
<div v-for="entry in allPreviews" :key="entry.key" class="preview-card">
|
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 -->
|
<!-- Thumbnail -->
|
||||||
<div class="preview-thumb" @click="openPreview(entry.previewSrc)">
|
<div
|
||||||
<img v-if="entry.isImage" :src="entry.thumbSrc" :alt="entry.name" />
|
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">
|
<div v-else class="preview-thumb-icon">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"
|
<svg
|
||||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
stroke-linejoin="round">
|
width="32"
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
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" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@ -249,10 +311,23 @@ function triggerDropzonePicker() {
|
|||||||
</p>
|
</p>
|
||||||
<p class="preview-size">{{ entry.sizeLabel }}</p>
|
<p class="preview-size">{{ entry.sizeLabel }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="preview-remove" title="Hapus" @click="entry.onRemove()">
|
<button
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
type="button"
|
||||||
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
class="preview-remove"
|
||||||
stroke-linejoin="round">
|
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="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>
|
||||||
@ -263,7 +338,11 @@ function triggerDropzonePicker() {
|
|||||||
|
|
||||||
<FieldError :errors="errors" />
|
<FieldError :errors="errors" />
|
||||||
|
|
||||||
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
|
<MediaPreviewDialog
|
||||||
|
v-model:open="previewOpen"
|
||||||
|
:url="previewUrl"
|
||||||
|
:title="label"
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -340,7 +419,8 @@ function triggerDropzonePicker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Message slot wrapper */
|
/* Message slot wrapper */
|
||||||
.dropzone-wrapper :deep(.dz-message) {
|
.dropzone-wrapper :deep(.dropzone__message),
|
||||||
|
.dropzone-wrapper :deep([class*='dz-message-']) {
|
||||||
flex: 1 !important;
|
flex: 1 !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
|
|||||||
@ -15,8 +15,20 @@ const props = defineProps<{
|
|||||||
data: HomepageSettingsData;
|
data: HomepageSettingsData;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const heroImage = ref<MediaUploadState>(createMediaUploadState());
|
const heroImage = ref<MediaUploadState>(
|
||||||
const aboutImage = ref<MediaUploadState>(createMediaUploadState());
|
createMediaUploadState(
|
||||||
|
props.data.hero_image_url
|
||||||
|
? [{ id: 1, url: props.data.hero_image_url, thumb_url: props.data.hero_image_url }]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const aboutImage = ref<MediaUploadState>(
|
||||||
|
createMediaUploadState(
|
||||||
|
props.data.about_image_url
|
||||||
|
? [{ id: 2, url: props.data.about_image_url, thumb_url: props.data.about_image_url }]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
const galleryState = ref<MediaUploadState>(
|
const galleryState = ref<MediaUploadState>(
|
||||||
createMediaUploadState(props.data.gallery_images),
|
createMediaUploadState(props.data.gallery_images),
|
||||||
);
|
);
|
||||||
@ -46,8 +58,17 @@ function submit() {
|
|||||||
router.put(update.url(), formData, {
|
router.put(update.url(), formData, {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
heroImage.value = createMediaUploadState();
|
heroImage.value = createMediaUploadState(
|
||||||
aboutImage.value = createMediaUploadState();
|
props.data.hero_image_url
|
||||||
|
? [{ id: 1, url: props.data.hero_image_url, thumb_url: props.data.hero_image_url }]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
aboutImage.value = createMediaUploadState(
|
||||||
|
props.data.about_image_url
|
||||||
|
? [{ id: 2, url: props.data.about_image_url, thumb_url: props.data.about_image_url }]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
galleryState.value = createMediaUploadState(props.data.gallery_images);
|
||||||
},
|
},
|
||||||
onError: (errors: any) => {
|
onError: (errors: any) => {
|
||||||
if (errors.system) {
|
if (errors.system) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user