282 lines
6.7 KiB
Vue
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>
|