feat: enhance file upload and preview functionality

- Improved the uploadSmallFile method to handle various request body types (buffer, string, object, stream) more effectively.
- Added error handling for unsupported request body types.
- Implemented JSON file preview capability in FilePreviewModal, allowing users to view formatted JSON content.
- Updated localization files to include "retry" messages in multiple languages for better user experience during upload errors.
This commit is contained in:
Daniel Luiz Alves
2025-06-20 14:43:27 -03:00
parent b65aac3044
commit 1125665bb1
20 changed files with 204 additions and 25 deletions

View File

@@ -98,43 +98,82 @@ export class FilesystemController {
} catch (error) {
try {
await fs.promises.unlink(tempPath);
} catch (error) {
console.error("Error deleting temp file:", error);
} catch (cleanupError) {
console.error("Error deleting temp file:", cleanupError);
}
throw error;
}
}
private async uploadSmallFile(request: FastifyRequest, provider: FilesystemStorageProvider, objectName: string) {
const stream = request.body as any;
const chunks: Buffer[] = [];
const body = request.body as any;
return new Promise<void>((resolve, reject) => {
stream.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
if (Buffer.isBuffer(body)) {
if (body.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, body);
return;
}
stream.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
if (typeof body === "string") {
const buffer = Buffer.from(body, "utf8");
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
return;
}
if (buffer.length === 0) {
throw new Error("No file data received");
if (typeof body === "object" && body !== null && !body.on) {
const buffer = Buffer.from(JSON.stringify(body), "utf8");
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
return;
}
if (body && typeof body.on === "function") {
const chunks: Buffer[] = [];
return new Promise<void>((resolve, reject) => {
body.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
body.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
resolve();
} catch (error) {
console.error("Error uploading small file:", error);
reject(error);
}
});
await provider.uploadFile(objectName, buffer);
resolve();
} catch (error) {
console.error("Error uploading small file:", error);
body.on("error", (error: Error) => {
console.error("Error reading upload stream:", error);
reject(error);
}
});
});
}
stream.on("error", (error: Error) => {
console.error("Error reading upload stream:", error);
reject(error);
});
});
try {
const buffer = Buffer.from(body);
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
} catch (error) {
throw new Error(`Unsupported request body type: ${typeof body}. Expected stream, buffer, string, or object.`);
}
}
async download(request: FastifyRequest, reply: FastifyReply) {

View File

@@ -9,6 +9,10 @@ export async function filesystemRoutes(app: FastifyInstance) {
return payload;
});
app.addContentTypeParser("application/json", async (request: FastifyRequest, payload: any) => {
return payload;
});
app.put(
"/filesystem/upload/:token",
{

View File

@@ -758,6 +758,7 @@
"uploadProgress": "تقدم الرفع",
"upload": "رفع",
"startUploads": "بدء الرفع",
"retry": "إعادة المحاولة",
"finish": "إنهاء",
"success": "تم رفع الملف بنجاح",
"allSuccess": "{count, plural, =1 {تم رفع الملف بنجاح} other {تم رفع # ملف بنجاح}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "هذا الرابط غير نشط.",
"linkExpired": "هذا الرابط منتهي الصلاحية.",
"uploadFailed": "خطأ في رفع الملف",
"retry": "إعادة المحاولة",
"fileTooLarge": "الملف كبير جداً. الحجم الأقصى: {maxSize}",
"fileTypeNotAllowed": "نوع الملف غير مسموح به. الأنواع المقبولة: {allowedTypes}",
"maxFilesExceeded": "الحد الأقصى المسموح به هو {maxFiles} ملف/ملفات",

View File

@@ -758,6 +758,7 @@
"uploadProgress": "Upload-Fortschritt",
"upload": "Hochladen",
"startUploads": "Uploads Starten",
"retry": "Wiederholen",
"finish": "Beenden",
"success": "Datei erfolgreich hochgeladen",
"allSuccess": "{count, plural, =1 {Datei erfolgreich hochgeladen} other {# Dateien erfolgreich hochgeladen}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "Dieser Link ist inaktiv.",
"linkExpired": "Dieser Link ist abgelaufen.",
"uploadFailed": "Fehler beim Hochladen der Datei",
"retry": "Wiederholen",
"fileTooLarge": "Datei zu groß. Maximale Größe: {maxSize}",
"fileTypeNotAllowed": "Dateityp nicht erlaubt. Erlaubte Typen: {allowedTypes}",
"maxFilesExceeded": "Maximal {maxFiles} Dateien erlaubt",

View File

@@ -816,6 +816,7 @@
"uploadProgress": "Upload progress",
"upload": "Upload",
"startUploads": "Start Uploads",
"retry": "Retry",
"finish": "Finish",
"success": "File uploaded successfully",
"allSuccess": "{count, plural, =1 {File uploaded successfully} other {# files uploaded successfully}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "This link is inactive.",
"linkExpired": "This link has expired.",
"uploadFailed": "Error uploading file",
"retry": "Retry",
"fileTooLarge": "File too large. Maximum size: {maxSize}",
"fileTypeNotAllowed": "File type not allowed. Accepted types: {allowedTypes}",
"maxFilesExceeded": "Maximum of {maxFiles} files allowed",

View File

@@ -758,6 +758,7 @@
"uploadProgress": "Progreso de la subida",
"upload": "Subir",
"startUploads": "Iniciar Subidas",
"retry": "Reintentar",
"finish": "Finalizar",
"success": "Archivo subido exitosamente",
"allSuccess": "{count, plural, =1 {Archivo subido exitosamente} other {# archivos subidos exitosamente}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "Este enlace está inactivo.",
"linkExpired": "Este enlace ha expirado.",
"uploadFailed": "Error al subir archivo",
"retry": "Reintentar",
"fileTooLarge": "Archivo demasiado grande. Tamaño máximo: {maxSize}",
"fileTypeNotAllowed": "Tipo de archivo no permitido. Tipos aceptados: {allowedTypes}",
"maxFilesExceeded": "Máximo de {maxFiles} archivos permitidos",

View File

@@ -758,6 +758,7 @@
"uploadProgress": "Progression du téléchargement",
"upload": "Télécharger",
"startUploads": "Commencer les Téléchargements",
"retry": "Réessayer",
"finish": "Terminer",
"success": "Fichier téléchargé avec succès",
"allSuccess": "{count, plural, =1 {Fichier téléchargé avec succès} other {# fichiers téléchargés avec succès}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "Ce lien est inactif.",
"linkExpired": "Ce lien a expiré.",
"uploadFailed": "Erreur lors de l'envoi du fichier",
"retry": "Réessayer",
"fileTooLarge": "Fichier trop volumineux. Taille maximale : {maxSize}",
"fileTypeNotAllowed": "Type de fichier non autorisé. Types acceptés : {allowedTypes}",
"maxFilesExceeded": "Maximum de {maxFiles} fichiers autorisés",

View File

@@ -758,6 +758,7 @@
"uploadProgress": "अपलोड प्रगति",
"upload": "अपलोड",
"startUploads": "अपलोड शुरू करें",
"retry": "पुनः प्रयास करें",
"finish": "समाप्त",
"success": "फ़ाइल सफलतापूर्वक अपलोड की गई",
"allSuccess": "{count, plural, =1 {फ़ाइल सफलतापूर्वक अपलोड की गई} other {# फ़ाइलें सफलतापूर्वक अपलोड की गईं}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "यह लिंक निष्क्रिय है।",
"linkExpired": "यह लिंक समाप्त हो गया है।",
"uploadFailed": "फ़ाइल अपलोड करने में त्रुटि",
"retry": "पुनः प्रयास करें",
"fileTooLarge": "फ़ाइल बहुत बड़ी है। अधिकतम आकार: {maxSize}",
"fileTypeNotAllowed": "फ़ाइल प्रकार अनुमत नहीं है। स्वीकृत प्रकार: {allowedTypes}",
"maxFilesExceeded": "अधिकतम {maxFiles} फ़ाइलें अनुमत हैं",

View File

@@ -758,6 +758,7 @@
"uploadProgress": "Progresso caricamento",
"upload": "Carica",
"startUploads": "Inizia Caricamenti",
"retry": "Riprova",
"finish": "Termina",
"success": "File caricato con successo",
"allSuccess": "{count, plural, =1 {File caricato con successo} other {# file caricati con successo}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "Questo link è inattivo.",
"linkExpired": "Questo link è scaduto.",
"uploadFailed": "Errore durante l'invio del file",
"retry": "Riprova",
"fileTooLarge": "File troppo grande. Dimensione massima: {maxSize}",
"fileTypeNotAllowed": "Tipo di file non consentito. Tipi accettati: {allowedTypes}",
"maxFilesExceeded": "Massimo {maxFiles} file consentiti",

View File

@@ -771,6 +771,7 @@
},
"multipleTitle": "複数ファイルをアップロード",
"startUploads": "アップロードを開始",
"retry": "再試行",
"allSuccess": "{count, plural, =1 {ファイルがアップロードされました} other {#個のファイルがアップロードされました}}",
"partialSuccess": "{success}個のファイルがアップロードされ、{error}個が失敗しました",
"dragAndDrop": "またはここにファイルをドラッグ&ドロップ"
@@ -1280,6 +1281,7 @@
"linkInactive": "このリンクは無効です。",
"linkExpired": "このリンクは期限切れです。",
"uploadFailed": "ファイルのアップロードに失敗しました",
"retry": "再試行",
"fileTooLarge": "ファイルが大きすぎます。最大サイズ: {maxSize}",
"fileTypeNotAllowed": "このファイル形式は許可されていません。許可される形式: {allowedTypes}",
"maxFilesExceeded": "最大 {maxFiles} ファイルまで許可されています",

View File

@@ -758,6 +758,7 @@
"uploadProgress": "업로드 진행률",
"upload": "업로드",
"startUploads": "업로드 시작",
"retry": "다시 시도",
"finish": "완료",
"success": "파일이 성공적으로 업로드되었습니다",
"allSuccess": "{count, plural, =1 {파일이 성공적으로 업로드되었습니다} other {# 개 파일이 성공적으로 업로드되었습니다}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "이 링크는 비활성 상태입니다.",
"linkExpired": "이 링크는 만료되었습니다.",
"uploadFailed": "파일 업로드 오류",
"retry": "다시 시도",
"fileTooLarge": "파일이 너무 큽니다. 최대 크기: {maxSize}",
"fileTypeNotAllowed": "허용되지 않는 파일 유형입니다. 허용된 유형: {allowedTypes}",
"maxFilesExceeded": "최대 {maxFiles}개의 파일만 허용됩니다",

View File

@@ -758,6 +758,7 @@
"uploadProgress": "Upload voortgang",
"upload": "Uploaden",
"startUploads": "Uploads Starten",
"retry": "Opnieuw Proberen",
"finish": "Voltooien",
"success": "Bestand succesvol geüpload",
"allSuccess": "{count, plural, =1 {Bestand succesvol geüpload} other {# bestanden succesvol geüpload}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "Deze link is inactief.",
"linkExpired": "Deze link is verlopen.",
"uploadFailed": "Fout bij uploaden bestand",
"retry": "Opnieuw Proberen",
"fileTooLarge": "Bestand te groot. Maximum grootte: {maxSize}",
"fileTypeNotAllowed": "Bestandstype niet toegestaan. Toegestane types: {allowedTypes}",
"maxFilesExceeded": "Maximum van {maxFiles} bestanden toegestaan",

View File

@@ -816,6 +816,7 @@
"uploadProgress": "Postęp przesyłania",
"upload": "Prześlij",
"startUploads": "Rozpocznij przesyłanie",
"retry": "Spróbuj Ponownie",
"finish": "Zakończ",
"success": "Plik przesłany pomyślnie",
"allSuccess": "{count, plural, =1 {Plik przesłany pomyślnie} other {# plików przesłanych pomyślnie}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "Ten link jest nieaktywny.",
"linkExpired": "Ten link wygasł.",
"uploadFailed": "Błąd przesyłania pliku",
"retry": "Spróbuj Ponownie",
"fileTooLarge": "Plik za duży. Maksymalny rozmiar: {maxSize}",
"fileTypeNotAllowed": "Typ pliku niedozwolony. Akceptowane typy: {allowedTypes}",
"maxFilesExceeded": "Dozwolono maksymalnie {maxFiles} plików",

View File

@@ -780,6 +780,7 @@
"uploadProgress": "Progresso do upload",
"upload": "Enviar",
"startUploads": "Iniciar Uploads",
"retry": "Tentar Novamente",
"finish": "Concluir",
"success": "Arquivo enviado com sucesso",
"allSuccess": "{count, plural, =1 {Arquivo enviado com sucesso} other {# arquivos enviados com sucesso}}",
@@ -1276,6 +1277,7 @@
"linkInactive": "Este link está inativo.",
"linkExpired": "Este link expirou.",
"uploadFailed": "Erro ao enviar arquivo",
"retry": "Tentar Novamente",
"fileTooLarge": "Arquivo muito grande. Tamanho máximo: {maxSize}",
"fileTypeNotAllowed": "Tipo de arquivo não permitido. Tipos aceitos: {allowedTypes}",
"maxFilesExceeded": "Máximo de {maxFiles} arquivos permitidos",

View File

@@ -758,6 +758,7 @@
"uploadProgress": "Прогресс загрузки",
"upload": "Загрузить",
"startUploads": "Начать Загрузку",
"retry": "Повторить",
"finish": "Завершить",
"success": "Файл успешно загружен",
"allSuccess": "{count, plural, =1 {Файл успешно загружен} other {# файлов успешно загружено}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "Эта ссылка неактивна.",
"linkExpired": "Срок действия этой ссылки истек.",
"uploadFailed": "Ошибка при загрузке файла",
"retry": "Повторить",
"fileTooLarge": "Файл слишком большой. Максимальный размер: {maxSize}",
"fileTypeNotAllowed": "Тип файла не разрешен. Разрешенные типы: {allowedTypes}",
"maxFilesExceeded": "Максимально разрешено {maxFiles} файлов",

View File

@@ -758,6 +758,7 @@
"uploadProgress": "Yükleme ilerlemesi",
"upload": "Yükle",
"startUploads": "Yüklemeleri Başlat",
"retry": "Tekrar Dene",
"finish": "Bitir",
"success": "Dosya başarıyla yüklendi",
"allSuccess": "{count, plural, =1 {Dosya başarıyla yüklendi} other {# dosya başarıyla yüklendi}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "Bu bağlantı pasif durumda.",
"linkExpired": "Bu bağlantının süresi doldu.",
"uploadFailed": "Dosya yüklenirken hata oluştu",
"retry": "Tekrar Dene",
"fileTooLarge": "Dosya çok büyük. Maksimum boyut: {maxSize}",
"fileTypeNotAllowed": "Dosya türüne izin verilmiyor. İzin verilen türler: {allowedTypes}",
"maxFilesExceeded": "Maksimum {maxFiles} dosyaya izin veriliyor",

View File

@@ -758,6 +758,7 @@
"uploadProgress": "上传进度",
"upload": "上传",
"startUploads": "开始上传",
"retry": "重试",
"finish": "完成",
"success": "文件上传成功",
"allSuccess": "{count, plural, =1 {文件上传成功} other {# 个文件上传成功}}",
@@ -1280,6 +1281,7 @@
"linkInactive": "此链接已停用。",
"linkExpired": "此链接已过期。",
"uploadFailed": "上传文件时出错",
"retry": "重试",
"fileTooLarge": "文件太大。最大大小:{maxSize}",
"fileTypeNotAllowed": "不允许的文件类型。允许的类型:{allowedTypes}",
"maxFilesExceeded": "最多允许 {maxFiles} 个文件",

View File

@@ -331,6 +331,28 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
<IconX className="h-4 w-4" />
</Button>
)}
{fileWithProgress.status === FILE_STATUS.ERROR && (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => {
setFiles((prev) =>
prev.map((file, i) =>
i === index ? { ...file, status: FILE_STATUS.PENDING, error: undefined } : file
)
);
}}
disabled={isUploading}
title={t("reverseShares.upload.retry")}
>
<IconUpload className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
<IconX className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);

View File

@@ -32,6 +32,7 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [pdfLoadFailed, setPdfLoadFailed] = useState(false);
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
const [jsonContent, setJsonContent] = useState<string | null>(null);
useEffect(() => {
if (isOpen && file.objectName && !isLoadingPreview) {
@@ -41,6 +42,7 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
setPdfAsBlob(false);
setDownloadUrl(null);
setPdfLoadFailed(false);
setJsonContent(null);
loadPreview();
}
}, [file.objectName, isOpen]);
@@ -88,6 +90,8 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
await loadAudioPreview(url);
} else if (fileType === "pdf") {
await loadPdfPreview(url);
} else if (fileType === "json") {
await loadJsonPreview(url);
} else {
setPreviewUrl(url);
}
@@ -155,6 +159,29 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
}
};
const loadJsonPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const text = await response.text();
try {
// Validate and format JSON
const parsed = JSON.parse(text);
const formatted = JSON.stringify(parsed, null, 2);
setJsonContent(formatted);
} catch (jsonError) {
// If it's not valid JSON, show as plain text
setJsonContent(text);
}
} catch (error) {
console.error("Failed to load JSON:", error);
setJsonContent(null);
}
};
const handlePdfLoadError = async () => {
if (pdfLoadFailed || pdfAsBlob) return;
@@ -193,6 +220,7 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension === "pdf") return "pdf";
if (extension === "json") return "json";
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) return "image";
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) return "audio";
if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) return "video";
@@ -225,7 +253,18 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
);
}
if (!previewUrl && fileType !== "video") {
// For JSON files, we don't need previewUrl, we use jsonContent instead
if (fileType === "json" && !jsonContent && !isLoading) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
if (!previewUrl && fileType !== "video" && fileType !== "json") {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
@@ -275,6 +314,25 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
</div>
</ScrollArea>
);
case "json":
return (
<ScrollArea className="w-full max-h-[600px]">
<div className="w-full border rounded-lg overflow-hidden bg-card">
{jsonContent ? (
<pre className="p-4 text-sm font-mono whitespace-pre-wrap break-words overflow-x-auto">
<code className="language-json">{jsonContent}</code>
</pre>
) : (
<div className="flex items-center justify-center h-32">
<div className="flex flex-col items-center gap-2">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
<p className="text-sm text-muted-foreground">{t("filePreview.loading")}</p>
</div>
</div>
)}
</div>
</ScrollArea>
);
case "image":
return (
<AspectRatio ratio={16 / 9} className="bg-muted">

View File

@@ -453,7 +453,33 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
>
<IconX size={14} />
</Button>
) : upload.status === UploadStatus.SUCCESS ? null : (
) : upload.status === UploadStatus.SUCCESS ? null : upload.status === UploadStatus.ERROR ? (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setFileUploads((prev) =>
prev.map((u) =>
u.id === upload.id ? { ...u, status: UploadStatus.PENDING, error: undefined } : u
)
);
}}
className="h-8 w-8 p-0"
title={t("uploadFile.retry")}
>
<IconLoader size={14} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(upload.id)}
className="h-8 w-8 p-0"
>
<IconTrash size={14} />
</Button>
</div>
) : (
<Button variant="ghost" size="sm" onClick={() => removeFile(upload.id)} className="h-8 w-8 p-0">
<IconTrash size={14} />
</Button>