Files
2026-05-13 17:11:09 +02:00

282 lines
6.7 KiB
Vue

<script setup>
import { inject, ref } from 'vue';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
uploadUrl: {
type: String,
default: null,
},
label: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const isDragOver = ref(false);
const isUploading = ref(false);
const error = ref(null);
const fileInputRef = ref(null);
const localPreview = ref(null);
// Plugs into the parent editor's pending-upload counter so the form submit
// can be blocked while this upload is in flight.
const pendingUploads = inject('editorPendingUploads', null);
const uploadFile = async (file) => {
if (!file) {
return;
}
if (!file.type.startsWith('image/')) {
error.value = 'Only image files are accepted.';
return;
}
// Emit blob URL immediately so the right-side preview updates at once
if (localPreview.value) {
URL.revokeObjectURL(localPreview.value);
}
localPreview.value = URL.createObjectURL(file);
error.value = null;
emit('update:modelValue', localPreview.value);
if (!props.uploadUrl) {
// No upload endpoint — blob URL stays as the value
return;
}
isUploading.value = true;
if (pendingUploads) pendingUploads.value++;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
const formData = new FormData();
formData.append('image', file);
const response = await fetch(props.uploadUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken },
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const data = await response.json();
// Replace blob URL with the permanent server URL
URL.revokeObjectURL(localPreview.value);
localPreview.value = null;
emit('update:modelValue', data.url);
} catch {
error.value = 'Upload failed. Please try again.';
URL.revokeObjectURL(localPreview.value);
localPreview.value = null;
emit('update:modelValue', '');
} finally {
isUploading.value = false;
if (pendingUploads) pendingUploads.value--;
}
};
const onDragOver = (event) => {
event.preventDefault();
isDragOver.value = true;
};
const onDragLeave = () => {
isDragOver.value = false;
};
const onDrop = (event) => {
event.preventDefault();
isDragOver.value = false;
const file = event.dataTransfer?.files?.[0];
if (file) {
uploadFile(file);
}
};
const openPicker = () => {
fileInputRef.value.value = '';
fileInputRef.value.click();
};
const onFileSelected = (event) => {
const file = event.target.files[0];
if (file) {
uploadFile(file);
}
};
const clearImage = (event) => {
event.stopPropagation();
if (localPreview.value) {
URL.revokeObjectURL(localPreview.value);
localPreview.value = null;
}
emit('update:modelValue', '');
};
</script>
<template>
<div
class="image-drop-zone"
:class="{
'image-drop-zone--over': isDragOver,
'image-drop-zone--uploading': isUploading,
'image-drop-zone--filled': !!(localPreview || modelValue),
}"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
@click="openPicker"
>
<template v-if="localPreview || modelValue">
<img :src="localPreview || modelValue" class="image-drop-zone__preview" :class="{ 'image-drop-zone__preview--uploading': isUploading }" alt="">
<div class="image-drop-zone__overlay">
<span class="image-drop-zone__overlay-text">{{ isUploading ? 'Uploading…' : 'Drop or click to replace' }}</span>
<button v-if="!isUploading" type="button" class="image-drop-zone__clear" @click="clearImage" title="Remove image"></button>
</div>
</template>
<template v-else>
<div class="image-drop-zone__empty">
<span class="image-drop-zone__icon">{{ isUploading ? '⏳' : '🖼' }}</span>
<span class="image-drop-zone__hint">{{ isUploading ? 'Uploading…' : (uploadUrl ? 'Drop image or click to upload' : 'Drop image here') }}</span>
<span v-if="label" class="image-drop-zone__label">{{ label }}</span>
</div>
</template>
</div>
<p v-if="error" class="image-drop-zone__error">{{ error }}</p>
<input ref="fileInputRef" type="file" accept="image/*" class="image-drop-zone__input" @change="onFileSelected">
</template>
<style scoped>
.image-drop-zone {
border: 2px dashed rgba(15, 23, 42, 0.15);
border-radius: 0.75rem;
cursor: pointer;
overflow: hidden;
position: relative;
transition: border-color 0.15s, background 0.15s;
user-select: none;
}
.image-drop-zone--over {
background: #f0f9ff;
border-color: rgba(14, 116, 144, 0.6);
}
.image-drop-zone--uploading {
pointer-events: none;
}
.image-drop-zone--filled {
border-style: solid;
border-color: rgba(15, 23, 42, 0.12);
}
/* Empty state */
.image-drop-zone__empty {
align-items: center;
display: flex;
flex-direction: column;
gap: 0.35rem;
justify-content: center;
min-height: 6rem;
padding: 1rem;
}
.image-drop-zone__icon {
font-size: 1.6rem;
line-height: 1;
}
.image-drop-zone__hint {
color: #64748b;
font-size: 0.8rem;
font-weight: 600;
text-align: center;
}
.image-drop-zone__label {
color: #94a3b8;
font-size: 0.72rem;
text-align: center;
}
/* Filled state */
.image-drop-zone__preview {
display: block;
height: 9rem;
object-fit: cover;
width: 100%;
}
.image-drop-zone__preview--uploading {
opacity: 0.5;
}
.image-drop-zone__overlay {
align-items: center;
background: rgba(15, 23, 42, 0.5);
bottom: 0;
display: flex;
justify-content: space-between;
left: 0;
opacity: 0;
padding: 0.4rem 0.6rem;
position: absolute;
right: 0;
transition: opacity 0.15s;
}
.image-drop-zone:hover .image-drop-zone__overlay {
opacity: 1;
}
.image-drop-zone__overlay-text {
color: #fff;
font-size: 0.75rem;
font-weight: 600;
}
.image-drop-zone__clear {
background: rgba(255, 255, 255, 0.15);
border: 0;
border-radius: 50%;
color: #fff;
cursor: pointer;
font-size: 0.75rem;
height: 1.4rem;
line-height: 1;
padding: 0;
width: 1.4rem;
}
.image-drop-zone__clear:hover {
background: rgba(239, 68, 68, 0.75);
}
.image-drop-zone__input {
display: none;
}
.image-drop-zone__error {
color: #b91c1c;
font-size: 0.8rem;
margin: 0.25rem 0 0;
}
</style>