Files
BarangaySystem/resources/js/Components/Core/Dropzone.vue
2026-06-06 18:43:00 +08:00

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>