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:
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "node --test src/utils/uploadResponse.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/vazirmatn": "^5.3.0",
|
"@fontsource/vazirmatn": "^5.3.0",
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ axiosInstance.interceptors.request.use(
|
|||||||
config.params.lang = lang;
|
config.params.lang = lang;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFormData = typeof FormData !== 'undefined' && config.data instanceof FormData;
|
||||||
|
if (isFormData) {
|
||||||
|
// Let the browser set multipart boundary — a bare multipart/form-data header breaks Multer
|
||||||
|
if (typeof config.headers?.delete === 'function') {
|
||||||
|
config.headers.delete('Content-Type');
|
||||||
|
config.headers.delete('content-type');
|
||||||
|
} else {
|
||||||
|
delete config.headers['Content-Type'];
|
||||||
|
delete config.headers['content-type'];
|
||||||
|
}
|
||||||
|
config.timeout = Math.max(config.timeout || 0, 120000);
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ export const certificateApi = {
|
|||||||
search: (params) => axiosInstance.get('/certificates/admin/search', { params }),
|
search: (params) => axiosInstance.get('/certificates/admin/search', { params }),
|
||||||
getOne: (id) => axiosInstance.get(`/certificates/admin/get-one/${id}`),
|
getOne: (id) => axiosInstance.get(`/certificates/admin/get-one/${id}`),
|
||||||
getByUser: (userId) => axiosInstance.get(`/certificates/admin/by-user/${userId}`),
|
getByUser: (userId) => axiosInstance.get(`/certificates/admin/by-user/${userId}`),
|
||||||
create: (data) => axiosInstance.post('/certificates/admin/create', data),
|
create: (data) => axiosInstance.post('/certificates/admin/create', data, { timeout: 120000 }),
|
||||||
update: (id, data) => axiosInstance.put(`/certificates/admin/update/${id}`, data),
|
update: (id, data) => axiosInstance.put(`/certificates/admin/update/${id}`, data),
|
||||||
delete: (id) => axiosInstance.delete(`/certificates/admin/delete/${id}`),
|
delete: (id) => axiosInstance.delete(`/certificates/admin/delete/${id}`),
|
||||||
uploadTemp: (formData) => axiosInstance.post('/files/admin/upload-temp', formData, {
|
uploadTemp: (formData) => axiosInstance.post('/files/admin/upload-temp', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
timeout: 120000
|
||||||
}),
|
}),
|
||||||
getSignedUrl: (filename, bucket = 'temp') =>
|
getSignedUrl: (filename, bucket = 'temp') =>
|
||||||
axiosInstance.get(`/files/admin/signed-url/${encodeURIComponent(filename)}`, { params: { bucket } }),
|
axiosInstance.get(`/files/admin/signed-url/${encodeURIComponent(filename)}`, { params: { bucket } }),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const documentApi = {
|
|||||||
getAll: (params) => axiosInstance.get('/documents/admin/get-all', { params }),
|
getAll: (params) => axiosInstance.get('/documents/admin/get-all', { params }),
|
||||||
getByUser: (userId) => axiosInstance.get(`/documents/admin/by-user/${userId}`),
|
getByUser: (userId) => axiosInstance.get(`/documents/admin/by-user/${userId}`),
|
||||||
getOne: (id) => axiosInstance.get(`/documents/admin/get-one/${id}`),
|
getOne: (id) => axiosInstance.get(`/documents/admin/get-one/${id}`),
|
||||||
create: (data) => axiosInstance.post('/documents/admin/create', data),
|
create: (data) => axiosInstance.post('/documents/admin/create', data, { timeout: 120000 }),
|
||||||
update: (id, data) => axiosInstance.put(`/documents/admin/update/${id}`, data),
|
update: (id, data) => axiosInstance.put(`/documents/admin/update/${id}`, data),
|
||||||
delete: (id) => axiosInstance.delete(`/documents/admin/delete/${id}`),
|
delete: (id) => axiosInstance.delete(`/documents/admin/delete/${id}`),
|
||||||
getMyDocuments: () => axiosInstance.get('/documents/user/my-documents')
|
getMyDocuments: () => axiosInstance.get('/documents/user/my-documents')
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// /src/api/filesApi.js
|
||||||
|
import axiosInstance from './axiosInstance';
|
||||||
|
|
||||||
|
export const filesApi = {
|
||||||
|
uploadTemp: (formData) => axiosInstance.post('/files/admin/upload-temp', formData, {
|
||||||
|
timeout: 120000
|
||||||
|
}),
|
||||||
|
getSignedUrl: (filename, bucket = 'temp') =>
|
||||||
|
axiosInstance.get(`/files/admin/signed-url/${encodeURIComponent(filename)}`, { params: { bucket } }),
|
||||||
|
getContent: (key, bucket) => axiosInstance.get('/files/admin/content', {
|
||||||
|
params: { key, bucket },
|
||||||
|
responseType: 'blob',
|
||||||
|
timeout: 120000
|
||||||
|
})
|
||||||
|
};
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
<!-- /src/components/uploader/ImageManager.vue -->
|
<!-- FilePond → Node.js temp upload, private files previewed via authenticated API proxy -->
|
||||||
<template>
|
<template>
|
||||||
<div class="image-manager">
|
<div class="image-manager">
|
||||||
<file-pond
|
<file-pond
|
||||||
v-if="allowUpload"
|
v-if="allowUpload"
|
||||||
ref="pond"
|
ref="pond"
|
||||||
name="file"
|
name="filepond-image"
|
||||||
|
store-as-file="true"
|
||||||
:label-idle="labelIdle"
|
:label-idle="labelIdle"
|
||||||
|
label-file-processing="در حال آپلود"
|
||||||
|
label-file-processing-complete="آپلود فایل تکمیل شد"
|
||||||
:allow-multiple="allowMultiple"
|
:allow-multiple="allowMultiple"
|
||||||
:max-files="maxFiles"
|
:max-files="maxFiles"
|
||||||
:accepted-file-types="acceptedFileTypes"
|
:accepted-file-types="acceptedFileTypes"
|
||||||
@@ -20,24 +23,25 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(img, index) in imagesList"
|
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"
|
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">
|
<div class="gallery-card surface-card border-1 border-color border-round overflow-hidden relative">
|
||||||
<a
|
<a
|
||||||
:href="img.url || img.signedUrl || img.presignedUrl"
|
:href="previewSrc(img)"
|
||||||
:data-pswp-width="img.width || 1600"
|
:data-pswp-width="img.width || dimensions[itemKey(img, index)]?.width || 1600"
|
||||||
:data-pswp-height="img.height || 1200"
|
:data-pswp-height="img.height || dimensions[itemKey(img, index)]?.height || 1200"
|
||||||
:data-pswp-type="isPdf(img) ? 'iframe' : undefined"
|
:data-pswp-type="isPdf(img) ? 'iframe' : undefined"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="block overflow-hidden gallery-thumb"
|
class="block overflow-hidden gallery-thumb"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="!isPdf(img)"
|
v-if="!isPdf(img) && previewSrc(img)"
|
||||||
:src="img.url || img.signedUrl || img.presignedUrl"
|
:src="previewSrc(img)"
|
||||||
:alt="img.name || img.fileName || img.title || 'تصویر'"
|
:alt="img.name || img.fileName || img.title || 'تصویر'"
|
||||||
class="w-full h-10rem object-cover block"
|
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">
|
<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>
|
<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 'photoswipe/style.css';
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
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';
|
import Button from 'primevue/button';
|
||||||
|
|
||||||
const FilePond = vueFilePond(FilePondPluginFileValidateType, FilePondPluginImagePreview);
|
const FilePond = vueFilePond(FilePondPluginFileValidateType, FilePondPluginImagePreview);
|
||||||
@@ -93,6 +98,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'temp'
|
default: 'temp'
|
||||||
},
|
},
|
||||||
|
storageBucket: {
|
||||||
|
type: String,
|
||||||
|
default: 'certificates'
|
||||||
|
},
|
||||||
maxFiles: {
|
maxFiles: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 10
|
default: 10
|
||||||
@@ -124,6 +133,10 @@ const emit = defineEmits(['update:modelValue', 'upload-success', 'remove']);
|
|||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const pond = ref(null);
|
const pond = ref(null);
|
||||||
let lightbox = 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>';
|
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 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 isPdf = (img) => {
|
||||||
const name = `${img?.name || ''} ${img?.fileName || ''} ${img?.mimeType || ''} ${img?.url || ''}`.toLowerCase();
|
const name = `${img?.name || ''} ${img?.fileName || ''} ${img?.mimeType || ''} ${img?.url || ''}`.toLowerCase();
|
||||||
return name.includes('pdf') || name.includes('application/pdf');
|
return name.includes('pdf') || name.includes('application/pdf');
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseServerFileMeta = (serverId) => {
|
const previewSrc = (img) => {
|
||||||
if (!serverId) return { tempFileName: '', originalName: '', mimeType: '' };
|
const key = itemKey(img);
|
||||||
if (typeof serverId === 'object') {
|
return previewUrls.value[key] || img?.url || img?.signedUrl || img?.presignedUrl || '';
|
||||||
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 serverOptions = computed(() => ({
|
const authHeaders = () => ({
|
||||||
url: API_BASE_URL,
|
Authorization: `Bearer ${authStore.accessToken || localStorage.getItem('accessToken') || ''}`
|
||||||
process: {
|
|
||||||
url: props.uploadEndpoint,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${authStore.accessToken}`
|
|
||||||
},
|
|
||||||
onload: (response) => {
|
|
||||||
const meta = parseServerFileMeta(response);
|
|
||||||
return JSON.stringify({
|
|
||||||
tempFileName: meta.tempFileName,
|
|
||||||
originalName: meta.originalName,
|
|
||||||
mimeType: meta.mimeType
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const serverOptions = computed(() => ({
|
||||||
|
process: {
|
||||||
|
url: buildUploadUrl(API_BASE_URL, props.uploadEndpoint),
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders,
|
||||||
|
onload: (response) => {
|
||||||
|
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
|
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 {
|
try {
|
||||||
const res = await axiosInstance.get(
|
const blob = await filesApi.getContent(objectKey, bucket);
|
||||||
`/files/admin/signed-url/${encodeURIComponent(tempFileName)}`,
|
if (!(blob instanceof Blob) || !blob.size || /json|html/i.test(blob.type)) return;
|
||||||
{ params: { bucket: props.tempBucket } }
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
);
|
previewUrls.value = { ...previewUrls.value, [key]: objectUrl };
|
||||||
const data = res.data || res;
|
|
||||||
return data.presignedUrl || data.signedUrl || data.url || '';
|
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
// signedUrl from the API is a fallback for display
|
||||||
|
} finally {
|
||||||
|
inflightPreviews.delete(key);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProcessFile = async (error, file) => {
|
const handleProcessFile = async (error, file) => {
|
||||||
if (error) return;
|
if (error) return;
|
||||||
|
|
||||||
const meta = parseServerFileMeta(file.serverId);
|
const serverId = file?.serverId;
|
||||||
const tempFileName = meta.tempFileName || '';
|
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 originalName = meta.originalName || file.filename;
|
||||||
const mimeType = meta.mimeType || file.fileType || '';
|
const mimeType = meta.mimeType || file.fileType || '';
|
||||||
|
|
||||||
const signedUrl = tempFileName ? await fetchTempSignedUrl(tempFileName) : '';
|
|
||||||
|
|
||||||
const newItem = {
|
const newItem = {
|
||||||
name: originalName,
|
name: originalName,
|
||||||
fileName: originalName,
|
fileName: originalName,
|
||||||
tempFileName,
|
tempFileName,
|
||||||
mimeType,
|
mimeType,
|
||||||
|
bucket: meta.bucket || props.tempBucket,
|
||||||
pending: true,
|
pending: true,
|
||||||
url: signedUrl,
|
|
||||||
signedUrl,
|
|
||||||
width: 1600,
|
width: 1600,
|
||||||
height: 1200
|
height: 1200
|
||||||
};
|
};
|
||||||
|
|
||||||
emit('update:modelValue', [...imagesList.value, newItem]);
|
emit('update:modelValue', [...imagesList.value, newItem]);
|
||||||
emit('upload-success', newItem);
|
emit('upload-success', newItem);
|
||||||
|
await resolvePreview(newItem);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
try {
|
try {
|
||||||
pond.value?.removeFile?.(file.id);
|
pond.value?.removeFile?.(file.id);
|
||||||
@@ -223,12 +265,24 @@ const handleProcessFile = async (error, file) => {
|
|||||||
|
|
||||||
const removeGalleryImage = (index) => {
|
const removeGalleryImage = (index) => {
|
||||||
const item = imagesList.value[index];
|
const item = imagesList.value[index];
|
||||||
|
revokePreview(itemKey(item, index));
|
||||||
const updated = [...imagesList.value];
|
const updated = [...imagesList.value];
|
||||||
updated.splice(index, 1);
|
updated.splice(index, 1);
|
||||||
emit('update:modelValue', updated);
|
emit('update:modelValue', updated);
|
||||||
emit('remove', item);
|
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 = () => {
|
const destroyLightbox = () => {
|
||||||
if (lightbox) {
|
if (lightbox) {
|
||||||
lightbox.destroy();
|
lightbox.destroy();
|
||||||
@@ -245,7 +299,6 @@ const reinitLightbox = () => {
|
|||||||
gallery: `#${props.galleryId}`,
|
gallery: `#${props.galleryId}`,
|
||||||
children: 'a',
|
children: 'a',
|
||||||
pswpModule: () => import('photoswipe'),
|
pswpModule: () => import('photoswipe'),
|
||||||
// Enable zoom + swipe (PhotoSwipe defaults)
|
|
||||||
wheelToZoom: true,
|
wheelToZoom: true,
|
||||||
initialZoomLevel: 'fit',
|
initialZoomLevel: 'fit',
|
||||||
secondaryZoomLevel: 2.5,
|
secondaryZoomLevel: 2.5,
|
||||||
@@ -255,11 +308,19 @@ const reinitLightbox = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue?.length,
|
() => props.modelValue,
|
||||||
async () => {
|
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();
|
await nextTick();
|
||||||
reinitLightbox();
|
reinitLightbox();
|
||||||
}
|
},
|
||||||
|
{ deep: true, immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -269,6 +330,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
destroyLightbox();
|
destroyLightbox();
|
||||||
|
Object.keys(previewUrls.value).forEach(revokePreview);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
:allow-multiple="true"
|
:allow-multiple="true"
|
||||||
upload-endpoint="/files/admin/upload-temp"
|
upload-endpoint="/files/admin/upload-temp"
|
||||||
temp-bucket="temp"
|
temp-bucket="temp"
|
||||||
|
storage-bucket="certificates"
|
||||||
@upload-success="onCertificateUploaded"
|
@upload-success="onCertificateUploaded"
|
||||||
@remove="onCertificateRemove"
|
@remove="onCertificateRemove"
|
||||||
/>
|
/>
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
:allow-multiple="true"
|
:allow-multiple="true"
|
||||||
upload-endpoint="/files/admin/upload-temp"
|
upload-endpoint="/files/admin/upload-temp"
|
||||||
temp-bucket="temp"
|
temp-bucket="temp"
|
||||||
|
storage-bucket="documents"
|
||||||
@upload-success="onDocumentUploaded"
|
@upload-success="onDocumentUploaded"
|
||||||
@remove="onDocumentRemove"
|
@remove="onDocumentRemove"
|
||||||
/>
|
/>
|
||||||
@@ -110,6 +112,8 @@ const toGalleryItem = (item) => ({
|
|||||||
fileName: item.fileName || item.originalName,
|
fileName: item.fileName || item.originalName,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
mimeType: item.mimeType,
|
mimeType: item.mimeType,
|
||||||
|
fileKey: item.fileKey,
|
||||||
|
bucket: item.bucket,
|
||||||
url: item.url || item.signedUrl || item.presignedUrl || item.fileUrl,
|
url: item.url || item.signedUrl || item.presignedUrl || item.fileUrl,
|
||||||
signedUrl: item.signedUrl || item.presignedUrl,
|
signedUrl: item.signedUrl || item.presignedUrl,
|
||||||
fileUrl: item.fileUrl || null,
|
fileUrl: item.fileUrl || null,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Parse FilePond / Node.js upload JSON envelopes into a flat file meta object.
|
||||||
|
* Handles:
|
||||||
|
* - Gameno: { success, data: { tempFileName, originalName, mimeType } }
|
||||||
|
* - Chayamarket-style: { status, data: [{ fileRecord: { fileName } }] }
|
||||||
|
* - Unwrapped objects or a plain filename string
|
||||||
|
*/
|
||||||
|
export function parseUploadResponse(response) {
|
||||||
|
const empty = { tempFileName: '', originalName: '', mimeType: '', bucket: '' };
|
||||||
|
if (response == null || response === '') return empty;
|
||||||
|
|
||||||
|
let parsed = response;
|
||||||
|
if (typeof response === 'string') {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(response);
|
||||||
|
} catch {
|
||||||
|
return { ...empty, tempFileName: response };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed !== 'object') {
|
||||||
|
return { ...empty, tempFileName: String(parsed) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = parsed;
|
||||||
|
if (parsed.data !== undefined) {
|
||||||
|
payload = parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
payload = payload[0]?.fileRecord || payload[0] || {};
|
||||||
|
} else if (payload?.fileRecord && typeof payload.fileRecord === 'object') {
|
||||||
|
payload = payload.fileRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempFileName = payload.tempFileName
|
||||||
|
|| payload.fileName
|
||||||
|
|| payload.filename
|
||||||
|
|| payload.fileKey
|
||||||
|
|| '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
tempFileName: String(tempFileName || ''),
|
||||||
|
originalName: String(payload.originalName || payload.fileName || payload.filename || ''),
|
||||||
|
mimeType: String(payload.mimeType || payload.contentType || ''),
|
||||||
|
bucket: String(payload.bucket || payload.bucketName || '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUploadUrl(apiBaseUrl, endpoint) {
|
||||||
|
if (!endpoint) return apiBaseUrl || '';
|
||||||
|
if (/^https?:\/\//i.test(endpoint)) return endpoint;
|
||||||
|
const base = String(apiBaseUrl || '').replace(/\/$/, '');
|
||||||
|
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||||
|
return `${base}${path}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseUploadResponse, buildUploadUrl } from './uploadResponse.js';
|
||||||
|
|
||||||
|
describe('parseUploadResponse', () => {
|
||||||
|
it('reads Gameno { success, data } temp upload envelope', () => {
|
||||||
|
const meta = parseUploadResponse(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'File uploaded to temp bucket successfully',
|
||||||
|
data: {
|
||||||
|
tempFileName: 'temp-123.jpg',
|
||||||
|
originalName: 'scan.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
bucket: 'temp'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(meta.tempFileName, 'temp-123.jpg');
|
||||||
|
assert.equal(meta.originalName, 'scan.jpg');
|
||||||
|
assert.equal(meta.mimeType, 'image/jpeg');
|
||||||
|
assert.equal(meta.bucket, 'temp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads Chayamarket-style fileRecord array envelope', () => {
|
||||||
|
const meta = parseUploadResponse({
|
||||||
|
status: 'success',
|
||||||
|
data: [{ fileRecord: { fileName: 'abc-photo.png', mimeType: 'image/png' } }]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(meta.tempFileName, 'abc-photo.png');
|
||||||
|
assert.equal(meta.originalName, 'abc-photo.png');
|
||||||
|
assert.equal(meta.mimeType, 'image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a plain filename string as tempFileName', () => {
|
||||||
|
const meta = parseUploadResponse('temp-999.pdf');
|
||||||
|
assert.equal(meta.tempFileName, 'temp-999.pdf');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildUploadUrl', () => {
|
||||||
|
it('joins API base and relative FilePond endpoint without dropping /api', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildUploadUrl('http://localhost:5000/api', '/files/admin/upload-temp'),
|
||||||
|
'http://localhost:5000/api/files/admin/upload-temp'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an already-absolute process URL', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildUploadUrl('http://localhost:5000/api', 'https://api.example.com/files/admin/upload-temp'),
|
||||||
|
'https://api.example.com/files/admin/upload-temp'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user