Files
tehran_tala/apps/admin-panel/src/components/upload/GalleryUploadInput.vue
T

324 lines
8.7 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!--
Multi-file gallery uploader. Same `file/create` endpoint as
ImageUploadInput (one file per request), but files can be picked or dropped
in bulk; each uploads on its own with progress, and the bound value stays
the ordered list of stored URLs.
-->
<div class="gallery-upload">
<a-upload-dragger
accept="image/*"
multiple
:show-upload-list="false"
:custom-request="doUpload"
:before-upload="beforeUpload"
>
<div class="gallery-upload__zone">
<CloudUploadOutlined class="gallery-upload__icon" />
<p class="gallery-upload__title">{{ placeholder }}</p>
<p class="gallery-upload__hint">
تصاویر را اینجا بکشید و رها کنید یا کلیک کنید — انتخاب چند تصویر به‌صورت همزمان امکان‌پذیر است
</p>
</div>
</a-upload-dragger>
<div v-if="items.length" class="gallery-upload__grid">
<div
v-for="item in items"
:key="item.uid"
class="tile"
:class="{ 'is-uploading': item.status === 'uploading' }"
>
<img :src="item.url ?? item.preview" alt="" />
<div v-if="item.status === 'uploading'" class="tile__progress">
<a-progress type="circle" :percent="item.percent" :size="40" :stroke-width="8" stroke-color="#fff" trail-color="rgba(255,255,255,.3)" :show-info="false" />
</div>
<div v-else class="tile__overlay">
<button type="button" class="tile__btn" title="نمایش" @click="preview(item)">
<EyeOutlined />
</button>
<button type="button" class="tile__btn tile__btn--danger" title="حذف" @click="remove(item.uid)">
<DeleteOutlined />
</button>
</div>
</div>
</div>
<div v-if="items.length" class="gallery-upload__meta">
<span>{{ doneCount }} تصویر</span>
<span v-if="pendingCount" class="gallery-upload__pending">
<LoadingOutlined /> در حال بارگذاری {{ pendingCount }} تصویر…
</span>
</div>
<a-modal :open="previewVisible" :footer="null" @cancel="previewVisible = false">
<img :src="previewSrc" style="width: 100%" alt="" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { message, Upload } from 'ant-design-vue';
import { CloudUploadOutlined, DeleteOutlined, EyeOutlined, LoadingOutlined } from '@ant-design/icons-vue';
import api from '../../api/client';
const props = withDefaults(
defineProps<{
/** Ordered list of stored file URLs. */
modelValue?: string[];
/** Free-form tag recorded on each upload, e.g. 'product.gallery'. */
purpose?: string;
placeholder?: string;
}>(),
{ modelValue: () => [], purpose: undefined, placeholder: 'افزودن تصاویر گالری' },
);
const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>();
const MAX_PARALLEL = 3;
interface Item {
uid: string;
status: 'uploading' | 'done';
url?: string;
/** Local object URL shown while the file is still uploading. */
preview?: string;
percent: number;
}
const items = ref<Item[]>([]);
const previewVisible = ref(false);
const previewSrc = ref('');
const doneUrls = () => items.value.filter((i) => i.status === 'done' && i.url).map((i) => i.url!);
const doneCount = computed(() => items.value.filter((i) => i.status === 'done').length);
const pendingCount = computed(() => items.value.filter((i) => i.status === 'uploading').length);
function sync() {
emit('update:modelValue', doneUrls());
}
/** Keeps the tiles in step when the parent loads an existing record. */
watch(
() => props.modelValue,
(urls) => {
if (JSON.stringify(doneUrls()) === JSON.stringify(urls ?? [])) return;
const pending = items.value.filter((i) => i.status === 'uploading');
items.value = [
...(urls ?? []).map((url) => ({ uid: url, status: 'done' as const, url, percent: 100 })),
...pending,
];
},
{ immediate: true },
);
function beforeUpload(file: File) {
if (!file.type.startsWith('image/')) {
message.error(`«${file.name}» تصویر نیست`);
return Upload.LIST_IGNORE;
}
return true;
}
// A small queue so a 30-file drop doesn't open 30 requests at once.
let active = 0;
const queue: Array<() => void> = [];
function runQueued<T>(job: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const start = () => {
active++;
job()
.then(resolve, reject)
.finally(() => {
active--;
queue.shift()?.();
});
};
if (active < MAX_PARALLEL) start();
else queue.push(start);
});
}
const find = (uid: string) => items.value.find((i) => i.uid === uid);
function doUpload({ file }: { file: File & { uid?: string } }) {
const uid = file.uid ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
items.value.push({ uid, status: 'uploading', preview: URL.createObjectURL(file), percent: 0 });
runQueued(async () => {
// removed by the user while still waiting in the queue
if (!find(uid)) return null;
const form = new FormData();
form.append('file', file);
if (props.purpose) form.append('purpose', props.purpose);
// Let the browser set the multipart boundary.
const { data } = await api.post('file/create', form, {
onUploadProgress: (e) => {
const item = find(uid);
if (item && e.total) item.percent = Math.round((e.loaded / e.total) * 100);
},
});
return data as { url?: string };
})
.then((data) => {
const item = find(uid);
if (!item || !data?.url) return;
if (item.preview) URL.revokeObjectURL(item.preview);
Object.assign(item, { status: 'done', url: data.url, preview: undefined, percent: 100 });
sync();
})
.catch((e) => {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
`بارگذاری «${file.name}» ناموفق بود`;
message.error(msg);
dropItem(uid);
});
}
function dropItem(uid: string) {
const idx = items.value.findIndex((i) => i.uid === uid);
if (idx === -1) return;
const [gone] = items.value.splice(idx, 1);
if (gone.preview) URL.revokeObjectURL(gone.preview);
}
function remove(uid: string) {
dropItem(uid);
sync();
}
function preview(item: Item) {
previewSrc.value = item.url ?? item.preview ?? '';
previewVisible.value = true;
}
onBeforeUnmount(() => {
items.value.forEach((i) => i.preview && URL.revokeObjectURL(i.preview));
});
</script>
<style scoped>
.gallery-upload {
display: flex;
flex-direction: column;
gap: 12px;
}
.gallery-upload__zone {
padding: 18px 12px;
}
.gallery-upload__icon {
font-size: 16px;
color: #1c2d56;
opacity: 0.75;
}
.gallery-upload__title {
margin: 8px 0 2px;
font-weight: 500;
font-size: 14px;
color: #101828;
}
.gallery-upload__hint {
margin: 0;
font-size: 12px;
color: #8a94a6;
}
/* ── square thumbnails ── */
.gallery-upload__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 10px;
}
.tile {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
border: 1px solid #eef0f5;
background: #f5f7fb;
}
.tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tile.is-uploading img {
filter: brightness(0.6);
}
.tile__progress {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.tile__overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(16, 24, 40, 0.5);
opacity: 0;
transition: opacity 0.2s ease;
}
.tile:hover .tile__overlay,
.tile:focus-within .tile__overlay {
opacity: 1;
}
.tile__btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: none;
border-radius: 9px;
background: rgba(255, 255, 255, 0.92);
color: #1c2d56;
font-size: 15px;
cursor: pointer;
transition: transform 0.15s ease;
}
.tile__btn:hover {
transform: scale(1.08);
}
.tile__btn--danger {
color: #ea5455;
}
/* touch screens have no hover: keep the actions visible */
@media (hover: none) {
.tile__overlay {
opacity: 1;
align-items: flex-end;
justify-content: flex-end;
padding: 6px;
gap: 4px;
background: linear-gradient(to top, rgba(16, 24, 40, 0.55), transparent 55%);
}
.tile__btn {
width: 28px;
height: 28px;
font-size: 13px;
}
}
.gallery-upload__meta {
display: flex;
align-items: center;
gap: 14px;
font-size: 12px;
color: #8a94a6;
}
.gallery-upload__pending {
display: inline-flex;
align-items: center;
gap: 6px;
color: #1c2d56;
}
</style>