424 lines
9.6 KiB
Vue
424 lines
9.6 KiB
Vue
<template>
|
|
<div
|
|
class="dropzone-container"
|
|
:class="{ 'is-dragging': isDragging, 'has-error': !!error }"
|
|
@dragenter.prevent="isDragging = true"
|
|
@dragover.prevent="isDragging = true"
|
|
@dragleave.prevent="isDragging = false"
|
|
@drop.prevent="onDrop"
|
|
>
|
|
<div v-if="files.length === 0" class="dropzone-placeholder" @click="triggerFileInput">
|
|
<div class="icon-wrapper">
|
|
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/b1ef4a83a864.svg" class="cloud-icon" style="width: 48px; height: 48px; opacity: 0.5;" />
|
|
</div>
|
|
<p class="main-text">Click to upload or drag and drop</p>
|
|
<p class="sub-text">SVG, PNG, JPG or GIF (max. {{ maxSizeMB }}MB)</p>
|
|
</div>
|
|
|
|
<div v-else class="previews-grid">
|
|
<div v-for="(file, index) in files" :key="index" class="preview-card" :class="{ 'is-main': index === 0 }">
|
|
<label v-if="index === 0" class="main-photo-badge">Main Photo</label>
|
|
<img :src="file.preview" :alt="file.name" class="preview-img" />
|
|
<div class="preview-overlay">
|
|
<button v-if="index !== 0" @click.stop="setAsMain(index)" class="set-main-btn" title="Set as main photo">
|
|
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/8fad7e49d466.svg" style="width: 18px; height: 18px; filter: brightness(0) invert(1);" />
|
|
</button>
|
|
<button @click.stop="removeFile(index)" class="remove-btn" title="Remove image">
|
|
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/b6dc254166b4.bin" style="width: 16px; height: 16px; filter: brightness(0) invert(1);" />
|
|
</button>
|
|
</div>
|
|
<div v-if="file.uploading" class="progress-bar">
|
|
<div class="progress-fill" :style="{ width: file.progress + '%' }"></div>
|
|
</div>
|
|
<div v-if="file.error" class="error-badge" :title="file.error">!</div>
|
|
</div>
|
|
|
|
<div class="add-more-card" @click="triggerFileInput" title="Add more images">
|
|
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/8d3954387e14.svg" style="width: 24px; height: 24px; opacity: 0.6;" />
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
multiple
|
|
class="hidden-input"
|
|
:accept="accept"
|
|
@change="onFileChange"
|
|
/>
|
|
|
|
<transition name="fade">
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
</transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onUnmounted, watch } from 'vue';
|
|
|
|
const props = defineProps({
|
|
files: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
accept: {
|
|
type: String,
|
|
default: 'image/*'
|
|
},
|
|
maxSizeMB: {
|
|
type: Number,
|
|
default: 10
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['update:files', 'removed']);
|
|
|
|
const isDragging = ref(false);
|
|
const fileInput = ref(null);
|
|
const files = ref([]);
|
|
const error = ref(null);
|
|
|
|
// Initialize internal files with prop on mount
|
|
files.value = [...props.files];
|
|
|
|
// Sync internal files with prop when it explicitly changes reference or length
|
|
watch(() => props.files, (newFiles) => {
|
|
if (newFiles !== files.value) {
|
|
// Preserve internal hashes if they are not in the new prop yet
|
|
// This allows the child to keep the 'hashkey' it just received while the parent is still updating
|
|
files.value = newFiles.map((newF, index) => {
|
|
const existingF = files.value[index];
|
|
if (existingF && existingF.file === newF.file && !newF.hashkey && existingF.hashkey) {
|
|
return { ...newF, hashkey: existingF.hashkey };
|
|
}
|
|
return { ...newF };
|
|
});
|
|
}
|
|
}, { deep: false });
|
|
|
|
const triggerFileInput = () => {
|
|
fileInput.value.click();
|
|
};
|
|
|
|
const validateFile = (file) => {
|
|
if (file.size > props.maxSizeMB * 1024 * 1024) {
|
|
return { valid: false, error: `File ${file.name} is too large. Max ${props.maxSizeMB}MB.` };
|
|
}
|
|
|
|
const acceptedTypes = props.accept.split(',').map(t => t.trim());
|
|
const isAccepted = acceptedTypes.some(type => {
|
|
if (type.endsWith('/*')) {
|
|
return file.type.startsWith(type.replace('*', ''));
|
|
}
|
|
return file.type === type;
|
|
});
|
|
|
|
if (!isAccepted && props.accept !== '*/*') {
|
|
return { valid: false, error: `File ${file.name} has invalid type.` };
|
|
}
|
|
|
|
return { valid: true };
|
|
};
|
|
|
|
const addFiles = (newFiles) => {
|
|
error.value = null;
|
|
const filteredFiles = Array.from(newFiles).map(file => {
|
|
const validation = validateFile(file);
|
|
if (!validation.valid) {
|
|
error.value = validation.error;
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
file,
|
|
name: file.name,
|
|
preview: URL.createObjectURL(file),
|
|
uploading: false,
|
|
progress: 0,
|
|
error: null,
|
|
hashkey: null
|
|
};
|
|
}).filter(f => f !== null);
|
|
|
|
files.value = [...files.value, ...filteredFiles];
|
|
emit('update:files', files.value);
|
|
};
|
|
|
|
const onFileChange = (e) => {
|
|
addFiles(e.target.files);
|
|
};
|
|
|
|
const onDrop = (e) => {
|
|
isDragging.value = false;
|
|
addFiles(e.dataTransfer.files);
|
|
};
|
|
|
|
const removeFile = (index) => {
|
|
const removedFile = files.value[index];
|
|
if (removedFile.preview) {
|
|
URL.revokeObjectURL(removedFile.preview);
|
|
}
|
|
files.value.splice(index, 1);
|
|
emit('update:files', files.value);
|
|
if (removedFile.hashkey) {
|
|
emit('removed', removedFile.hashkey);
|
|
}
|
|
};
|
|
|
|
const setAsMain = (index) => {
|
|
if (index === 0) return;
|
|
const mainFile = files.value.splice(index, 1)[0];
|
|
files.value.unshift(mainFile);
|
|
emit('update:files', files.value);
|
|
};
|
|
|
|
onUnmounted(() => {
|
|
files.value.forEach(file => {
|
|
if (file.preview) URL.revokeObjectURL(file.preview);
|
|
});
|
|
});
|
|
|
|
defineExpose({
|
|
clear: () => {
|
|
files.value.forEach(file => {
|
|
if (file.preview) URL.revokeObjectURL(file.preview);
|
|
});
|
|
files.value = [];
|
|
emit('update:files', []);
|
|
},
|
|
setFileStatus: (index, status) => {
|
|
if (files.value[index]) {
|
|
files.value[index] = { ...files.value[index], ...status };
|
|
emit('update:files', [...files.value]);
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dropzone-container {
|
|
width: 100%;
|
|
min-height: 180px;
|
|
border: 2px dashed #e2e8f0;
|
|
border-radius: 16px;
|
|
background: #f8fafc;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
position: relative;
|
|
overflow: hidden;
|
|
padding: 20px;
|
|
}
|
|
|
|
.dropzone-container:hover {
|
|
border-color: #3b82f6;
|
|
background: #eff6ff;
|
|
}
|
|
|
|
.dropzone-container.is-dragging {
|
|
border-color: #3b82f6;
|
|
background: #dbeafe;
|
|
transform: scale(1.01);
|
|
}
|
|
|
|
.dropzone-container.has-error {
|
|
border-color: #ef4444;
|
|
background: #fef2f2;
|
|
}
|
|
|
|
.dropzone-placeholder {
|
|
text-align: center;
|
|
color: #64748b;
|
|
}
|
|
|
|
.icon-wrapper {
|
|
margin-bottom: 12px;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.cloud-icon {
|
|
width: 48px;
|
|
height: 48px;
|
|
}
|
|
|
|
.main-text {
|
|
font-weight: 600;
|
|
color: #1e293b;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.sub-text {
|
|
font-size: 0.85rem;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.previews-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
|
gap: 12px;
|
|
width: 100%;
|
|
}
|
|
|
|
.preview-card {
|
|
position: relative;
|
|
aspect-ratio: 1;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
background: #fff;
|
|
}
|
|
|
|
.preview-card.is-main {
|
|
border: 3px solid #3b82f6;
|
|
transform: scale(1.02);
|
|
z-index: 2;
|
|
}
|
|
|
|
.main-photo-badge {
|
|
position: absolute;
|
|
top: 8px;
|
|
left: 8px;
|
|
background: #2563eb !important;
|
|
color: white !important;
|
|
padding: 4px 10px;
|
|
border-radius: 6px;
|
|
font-size: 0.75rem;
|
|
font-weight: 800;
|
|
text-transform: uppercase;
|
|
z-index: 20;
|
|
box-shadow: 0 4px 6px rgba(37, 99, 235, 0.4);
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.preview-img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.preview-overlay {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
opacity: 0;
|
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
z-index: 25;
|
|
}
|
|
|
|
.preview-card:hover .preview-overlay {
|
|
opacity: 1;
|
|
transform: translateY(2px);
|
|
}
|
|
|
|
.remove-btn, .set-main-btn {
|
|
color: white !important;
|
|
border: none;
|
|
border-radius: 50%;
|
|
width: 34px;
|
|
height: 34px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.remove-btn {
|
|
background: #ef4444 !important;
|
|
}
|
|
|
|
.set-main-btn {
|
|
background: #2563eb !important;
|
|
}
|
|
|
|
.remove-btn svg, .set-main-btn svg {
|
|
display: block;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.remove-btn:hover, .set-main-btn:hover {
|
|
transform: scale(1.1);
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
.add-more-card {
|
|
aspect-ratio: 1;
|
|
border: 2px dashed #cbd5e1;
|
|
border-radius: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #94a3b8;
|
|
background: #fff;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.add-more-card:hover {
|
|
border-color: #3b82f6;
|
|
color: #3b82f6;
|
|
background: #f1f5f9;
|
|
}
|
|
|
|
.progress-bar {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 4px;
|
|
background: rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: #3b82f6;
|
|
transition: width 0.3s;
|
|
}
|
|
|
|
.error-badge {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 4px;
|
|
background: #ef4444;
|
|
color: white;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: bold;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.error-message {
|
|
position: absolute;
|
|
bottom: 8px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: #ef4444;
|
|
color: white;
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.8rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.hidden-input {
|
|
display: none;
|
|
}
|
|
|
|
.fade-enter-active, .fade-leave-active {
|
|
transition: opacity 0.3s;
|
|
}
|
|
.fade-enter-from, .fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|