Files
banu-front/src/components/form/FileUploader.vue
T
2026-05-27 14:47:19 +03:30

257 lines
6.0 KiB
Vue

<template>
<div class="file-uploader">
<label class="file-uploader__dropzone-wrap">
<div
class="file-uploader__dropzone"
:class="{ 'file-uploader__dropzone--active': isDragging }"
@dragover.prevent
@dragenter="isDragging = true"
@dragleave="isDragging = false"
@drop="handleDrop"
>
<SvgIcon name="upload" :size="32" class="file-uploader__upload-icon" />
<span class="file-uploader__upload-text">{{ uploadText }}</span>
<input
type="file"
class="file-uploader__input"
:accept="accept"
:multiple="multiple"
@change="handleFileSelect"
/>
</div>
</label>
<div v-if="modelValue.length > 0" class="file-uploader__list">
<div v-for="(file, index) in modelValue" :key="file.id ?? index" class="file-uploader__item">
<div class="file-uploader__item-info">
<SvgIcon name="file" :size="24" class="file-uploader__item-icon" />
<div class="file-uploader__item-meta">
<p class="file-uploader__item-name">{{ file.name }}</p>
<p class="file-uploader__item-size">{{ formatFileSize(file.size) }}</p>
</div>
</div>
<div class="file-uploader__item-actions">
<img
v-if="file.url && isImage(file)"
:src="file.url"
class="file-uploader__item-thumb"
alt="preview"
/>
<a v-else-if="file.url" :href="file.url" target="_blank" class="file-uploader__item-link">
دانلود
</a>
<CircleButton
tooltip="حذف"
bg-color="transparent"
size="2rem"
@click="removeFile(index, file)"
>
<template #icon>
<SvgIcon name="close" color="red" :size="16" />
</template>
</CircleButton>
</div>
</div>
</div>
<div v-else class="file-uploader__empty">هیچ فایلی آپلود نشده است</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
accept: { type: String, default: '*' },
multiple: { type: Boolean, default: true },
maxFiles: { type: Number, default: 10 },
maxSize: { type: Number, default: 0 },
})
const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
const isDragging = ref(false)
const uploadText = computed(() =>
props.modelValue.length === 0 ? 'فایل جدید اضافه کنید' : 'فایل دیگری اضافه کنید'
)
const handleFileSelect = (event) => {
const files = [...event.target.files]
emitFiles(files)
event.target.value = ''
}
const handleDrop = (event) => {
event.preventDefault()
isDragging.value = false
const files = [...event.dataTransfer.files]
emitFiles(files)
}
const emitFiles = (files) => {
if (props.modelValue.length + files.length > props.maxFiles) {
emit('error', `حداکثر تعداد فایل مجاز ${props.maxFiles} عدد است.`)
return
}
if (props.maxSize > 0) {
const tooBig = files.find((f) => f.size > props.maxSize)
if (tooBig) {
emit('error', `حجم فایل "${tooBig.name}" بیش از حد مجاز است.`)
return
}
}
emit('select', files)
}
const removeFile = (index, file) => {
const next = [...props.modelValue]
next.splice(index, 1)
emit('update:modelValue', next)
emit('remove', { index, file })
}
const isImage = (file) =>
file.type === 'image' || /\.(jpe?g|png|gif|webp|svg)$/i.test(file.name || '')
const formatFileSize = (bytes) => {
if (!bytes) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
</script>
<style lang="scss" scoped>
.file-uploader {
width: 100%;
&__dropzone-wrap {
display: block;
width: 100%;
cursor: pointer;
margin-bottom: 0.75rem;
}
&__dropzone {
border: 2px dashed #d1d5db;
border-radius: 0.5rem;
padding: 1rem;
text-align: center;
transition: border-color 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
&:hover,
&--active {
border-color: var(--color-primary);
}
}
&__upload-icon {
color: #9ca3af;
}
&__upload-text {
font-family: var(--font-family-fa);
font-size: 0.875rem;
font-weight: 500;
color: #4b5563;
}
&__input {
display: none;
}
&__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.625rem;
}
&__item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background-color: #eee;
border-radius: 0.5rem;
margin-bottom: 1rem;
gap: 0.75rem;
}
&__item-info {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
&__item-icon {
color: #6b7280;
flex-shrink: 0;
}
&__item-meta {
min-width: 0;
}
&__item-name {
font-family: var(--font-family-fa);
font-size: 0.875rem;
font-weight: 500;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__item-size {
font-family: var(--font-family-en);
font-size: 0.75rem;
color: #6b7280;
margin: 0;
}
&__item-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__item-thumb {
width: 4rem;
height: 3rem;
object-fit: cover;
border-radius: 0.25rem;
}
&__item-link {
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: var(--color-primary);
text-decoration: none;
&:hover {
opacity: 0.85;
}
}
&__empty {
text-align: center;
padding: 1rem 0;
font-family: var(--font-family-fa);
color: #6b7280;
font-size: 0.875rem;
}
}
</style>