Update
This commit is contained in:
281
resources/js/projects-renderer/components/ImageDropZone.vue
Normal file
281
resources/js/projects-renderer/components/ImageDropZone.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user