fix: align FilePond upload with the Node.js API and proxy gallery previews

Use the filepond-image field, parse the API envelope, and load private files through the authenticated content endpoint so uploads persist and images display.
This commit is contained in:
2026-08-15 18:46:26 +03:30
parent 67cba87661
commit 73670703bb
9 changed files with 263 additions and 57 deletions
+115 -53
View File
@@ -1,11 +1,14 @@
<!-- /src/components/uploader/ImageManager.vue -->
<!-- FilePond Node.js temp upload, private files previewed via authenticated API proxy -->
<template>
<div class="image-manager">
<file-pond
v-if="allowUpload"
ref="pond"
name="file"
name="filepond-image"
store-as-file="true"
:label-idle="labelIdle"
label-file-processing="در حال آپلود"
label-file-processing-complete="آپلود فایل تکمیل شد"
:allow-multiple="allowMultiple"
:max-files="maxFiles"
:accepted-file-types="acceptedFileTypes"
@@ -20,24 +23,25 @@
>
<div
v-for="(img, index) in imagesList"
:key="img.id || img.tempFileName || img.url || index"
:key="itemKey(img, index)"
class="col-12 sm:col-6 md:col-4 lg:col-3"
>
<div class="gallery-card surface-card border-1 border-color border-round overflow-hidden relative">
<a
:href="img.url || img.signedUrl || img.presignedUrl"
:data-pswp-width="img.width || 1600"
:data-pswp-height="img.height || 1200"
:href="previewSrc(img)"
:data-pswp-width="img.width || dimensions[itemKey(img, index)]?.width || 1600"
:data-pswp-height="img.height || dimensions[itemKey(img, index)]?.height || 1200"
:data-pswp-type="isPdf(img) ? 'iframe' : undefined"
target="_blank"
rel="noopener"
class="block overflow-hidden gallery-thumb"
>
<img
v-if="!isPdf(img)"
:src="img.url || img.signedUrl || img.presignedUrl"
v-if="!isPdf(img) && previewSrc(img)"
:src="previewSrc(img)"
:alt="img.name || img.fileName || img.title || 'تصویر'"
class="w-full h-10rem object-cover block"
@load="onImageLoad(img, index, $event)"
/>
<div v-else class="pdf-thumb flex flex-column align-items-center justify-content-center h-10rem gap-2">
<i class="pi pi-file-pdf text-3xl text-red-500"></i>
@@ -75,7 +79,8 @@ import PhotoSwipeLightbox from 'photoswipe/lightbox';
import 'photoswipe/style.css';
import { useAuthStore } from '@/stores/authStore';
import axiosInstance from '@/api/axiosInstance';
import { filesApi } from '@/api/filesApi';
import { parseUploadResponse, buildUploadUrl } from '@/utils/uploadResponse';
import Button from 'primevue/button';
const FilePond = vueFilePond(FilePondPluginFileValidateType, FilePondPluginImagePreview);
@@ -93,6 +98,10 @@ const props = defineProps({
type: String,
default: 'temp'
},
storageBucket: {
type: String,
default: 'certificates'
},
maxFiles: {
type: Number,
default: 10
@@ -124,6 +133,10 @@ const emit = defineEmits(['update:modelValue', 'upload-success', 'remove']);
const authStore = useAuthStore();
const pond = ref(null);
let lightbox = null;
const pendingMeta = new Map();
const inflightPreviews = new Set();
const previewUrls = ref({});
const dimensions = ref({});
const labelIdle = 'کشیدن و رها کردن فایل یا <span class="filepond--label-action">مرور سیستم</span>';
@@ -134,84 +147,113 @@ const imagesList = computed({
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api';
const itemKey = (img, index = 0) => img?.id || img?.fileKey || img?.tempFileName || img?.url || `idx-${index}`;
const isPdf = (img) => {
const name = `${img?.name || ''} ${img?.fileName || ''} ${img?.mimeType || ''} ${img?.url || ''}`.toLowerCase();
return name.includes('pdf') || name.includes('application/pdf');
};
const parseServerFileMeta = (serverId) => {
if (!serverId) return { tempFileName: '', originalName: '', mimeType: '' };
if (typeof serverId === 'object') {
const nested = serverId.data && typeof serverId.data === 'object' ? serverId.data : serverId;
return {
tempFileName: nested.tempFileName || nested.filename || '',
originalName: nested.originalName || nested.fileName || '',
mimeType: nested.mimeType || ''
};
}
try {
return parseServerFileMeta(JSON.parse(serverId));
} catch {
return { tempFileName: String(serverId), originalName: '', mimeType: '' };
}
const previewSrc = (img) => {
const key = itemKey(img);
return previewUrls.value[key] || img?.url || img?.signedUrl || img?.presignedUrl || '';
};
const authHeaders = () => ({
Authorization: `Bearer ${authStore.accessToken || localStorage.getItem('accessToken') || ''}`
});
const serverOptions = computed(() => ({
url: API_BASE_URL,
process: {
url: props.uploadEndpoint,
headers: {
Authorization: `Bearer ${authStore.accessToken}`
},
url: buildUploadUrl(API_BASE_URL, props.uploadEndpoint),
method: 'POST',
headers: authHeaders,
onload: (response) => {
const meta = parseServerFileMeta(response);
return JSON.stringify({
tempFileName: meta.tempFileName,
originalName: meta.originalName,
mimeType: meta.mimeType
});
const meta = parseUploadResponse(response);
if (!meta.tempFileName) {
throw new Error('پاسخ آپلود نامعتبر است');
}
pendingMeta.set(meta.tempFileName, meta);
return meta.tempFileName;
},
onerror: (response) => {
try {
const parsed = typeof response === 'string' ? JSON.parse(response) : response;
return parsed?.error?.message || parsed?.message || 'آپلود ناموفق بود';
} catch {
return 'آپلود ناموفق بود';
}
}
},
revert: null
}));
const fetchTempSignedUrl = async (tempFileName) => {
const revokePreview = (key) => {
const url = previewUrls.value[key];
if (url && String(url).startsWith('blob:')) {
URL.revokeObjectURL(url);
}
if (previewUrls.value[key]) {
const next = { ...previewUrls.value };
delete next[key];
previewUrls.value = next;
}
};
const resolvePreview = async (img) => {
const key = itemKey(img);
if (!key || previewUrls.value[key] || inflightPreviews.has(key)) return;
if (img?.url && String(img.url).startsWith('blob:')) {
previewUrls.value = { ...previewUrls.value, [key]: img.url };
return;
}
const objectKey = img.fileKey || img.tempFileName;
const bucket = img.fileKey
? (img.bucket || props.storageBucket || 'certificates')
: (img.bucket || props.tempBucket || 'temp');
if (!objectKey) return;
inflightPreviews.add(key);
try {
const res = await axiosInstance.get(
`/files/admin/signed-url/${encodeURIComponent(tempFileName)}`,
{ params: { bucket: props.tempBucket } }
);
const data = res.data || res;
return data.presignedUrl || data.signedUrl || data.url || '';
const blob = await filesApi.getContent(objectKey, bucket);
if (!(blob instanceof Blob) || !blob.size || /json|html/i.test(blob.type)) return;
const objectUrl = URL.createObjectURL(blob);
previewUrls.value = { ...previewUrls.value, [key]: objectUrl };
} catch {
return '';
// signedUrl from the API is a fallback for display
} finally {
inflightPreviews.delete(key);
}
};
const handleProcessFile = async (error, file) => {
if (error) return;
const meta = parseServerFileMeta(file.serverId);
const tempFileName = meta.tempFileName || '';
const serverId = file?.serverId;
const parsed = parseUploadResponse(serverId);
const tempFileName = parsed.tempFileName || (typeof serverId === 'string' ? serverId : '');
const meta = pendingMeta.get(tempFileName) || parsed;
pendingMeta.delete(tempFileName);
const originalName = meta.originalName || file.filename;
const mimeType = meta.mimeType || file.fileType || '';
const signedUrl = tempFileName ? await fetchTempSignedUrl(tempFileName) : '';
const newItem = {
name: originalName,
fileName: originalName,
tempFileName,
mimeType,
bucket: meta.bucket || props.tempBucket,
pending: true,
url: signedUrl,
signedUrl,
width: 1600,
height: 1200
};
emit('update:modelValue', [...imagesList.value, newItem]);
emit('upload-success', newItem);
await resolvePreview(newItem);
await nextTick();
try {
pond.value?.removeFile?.(file.id);
@@ -223,12 +265,24 @@ const handleProcessFile = async (error, file) => {
const removeGalleryImage = (index) => {
const item = imagesList.value[index];
revokePreview(itemKey(item, index));
const updated = [...imagesList.value];
updated.splice(index, 1);
emit('update:modelValue', updated);
emit('remove', item);
};
const onImageLoad = (img, index, event) => {
const el = event.target;
if (!el?.naturalWidth) return;
const key = itemKey(img, index);
dimensions.value = {
...dimensions.value,
[key]: { width: el.naturalWidth, height: el.naturalHeight }
};
reinitLightbox();
};
const destroyLightbox = () => {
if (lightbox) {
lightbox.destroy();
@@ -245,7 +299,6 @@ const reinitLightbox = () => {
gallery: `#${props.galleryId}`,
children: 'a',
pswpModule: () => import('photoswipe'),
// Enable zoom + swipe (PhotoSwipe defaults)
wheelToZoom: true,
initialZoomLevel: 'fit',
secondaryZoomLevel: 2.5,
@@ -255,11 +308,19 @@ const reinitLightbox = () => {
};
watch(
() => props.modelValue?.length,
async () => {
() => props.modelValue,
async (items) => {
const currentKeys = new Set((items || []).map((img, index) => itemKey(img, index)));
Object.keys(previewUrls.value).forEach((key) => {
if (!currentKeys.has(key)) revokePreview(key);
});
for (const img of items || []) {
await resolvePreview(img);
}
await nextTick();
reinitLightbox();
}
},
{ deep: true, immediate: true }
);
onMounted(async () => {
@@ -269,6 +330,7 @@ onMounted(async () => {
onUnmounted(() => {
destroyLightbox();
Object.keys(previewUrls.value).forEach(revokePreview);
});
</script>
@@ -25,6 +25,7 @@
:allow-multiple="true"
upload-endpoint="/files/admin/upload-temp"
temp-bucket="temp"
storage-bucket="certificates"
@upload-success="onCertificateUploaded"
@remove="onCertificateRemove"
/>
@@ -60,6 +61,7 @@
:allow-multiple="true"
upload-endpoint="/files/admin/upload-temp"
temp-bucket="temp"
storage-bucket="documents"
@upload-success="onDocumentUploaded"
@remove="onDocumentRemove"
/>
@@ -110,6 +112,8 @@ const toGalleryItem = (item) => ({
fileName: item.fileName || item.originalName,
title: item.title,
mimeType: item.mimeType,
fileKey: item.fileKey,
bucket: item.bucket,
url: item.url || item.signedUrl || item.presignedUrl || item.fileUrl,
signedUrl: item.signedUrl || item.presignedUrl,
fileUrl: item.fileUrl || null,