mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-06-26 16:53:03 +02:00
improve file upload (progress bar & validation) (#317)
* validate file * fix app_diff * rename types and functions * use accessible custom progress bar over native html
This commit is contained in:
parent
49ce9aee9e
commit
a436c1b658
@ -1,6 +1,6 @@
|
||||
--- template/app/src/file-upload/operations.ts
|
||||
+++ opensaas-sh/app/src/file-upload/operations.ts
|
||||
@@ -21,6 +21,18 @@
|
||||
@@ -18,6 +18,18 @@
|
||||
throw new HttpError(401);
|
||||
}
|
||||
|
||||
|
@ -1,20 +1,31 @@
|
||||
import { createFile, useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations';
|
||||
import axios from 'axios';
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { cn } from '../client/cn';
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import type { File } from 'wasp/entities';
|
||||
import { useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations';
|
||||
import { type FileUploadError, uploadFileWithProgress, validateFile, ALLOWED_FILE_TYPES } from './fileUploading';
|
||||
|
||||
export default function FileUploadPage() {
|
||||
const [fileToDownload, setFileToDownload] = useState<string>('');
|
||||
const [fileKeyForS3, setFileKeyForS3] = useState<File['key']>('');
|
||||
const [uploadProgressPercent, setUploadProgressPercent] = useState<number>(0);
|
||||
const [uploadError, setUploadError] = useState<FileUploadError | null>(null);
|
||||
|
||||
const { data: files, error: filesError, isLoading: isFilesLoading } = useQuery(getAllFilesByUser);
|
||||
const allUserFiles = useQuery(getAllFilesByUser, undefined, {
|
||||
// We disable automatic refetching because otherwise files would be refetched after `createFile` is called and the S3 URL is returned,
|
||||
// which happens before the file is actually fully uploaded. Instead, we manually (re)fetch on mount and after the upload is complete.
|
||||
enabled: false,
|
||||
});
|
||||
const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } = useQuery(
|
||||
getDownloadFileSignedURL,
|
||||
{ key: fileToDownload },
|
||||
{ key: fileKeyForS3 },
|
||||
{ enabled: false }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileToDownload.length > 0) {
|
||||
allUserFiles.refetch();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileKeyForS3.length > 0) {
|
||||
refetchDownloadUrl()
|
||||
.then((urlQuery) => {
|
||||
switch (urlQuery.status) {
|
||||
@ -28,38 +39,49 @@ export default function FileUploadPage() {
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setFileToDownload('');
|
||||
setFileKeyForS3('');
|
||||
});
|
||||
}
|
||||
}, [fileToDownload]);
|
||||
}, [fileKeyForS3]);
|
||||
|
||||
const handleUpload = async (e: FormEvent<HTMLFormElement>) => {
|
||||
try {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const file = formData.get('file-upload') as File;
|
||||
if (!file || !file.name || !file.type) {
|
||||
throw new Error('No file selected');
|
||||
|
||||
const formElement = e.target;
|
||||
if (!(formElement instanceof HTMLFormElement)) {
|
||||
throw new Error('Event target is not a form element');
|
||||
}
|
||||
|
||||
const fileType = file.type;
|
||||
const name = file.name;
|
||||
const formData = new FormData(formElement);
|
||||
const file = formData.get('file-upload');
|
||||
|
||||
const { uploadUrl } = await createFile({ fileType, name });
|
||||
if (!uploadUrl) {
|
||||
throw new Error('Failed to get upload URL');
|
||||
if (!file || !(file instanceof File)) {
|
||||
setUploadError({
|
||||
message: 'Please select a file to upload.',
|
||||
code: 'NO_FILE',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const res = await axios.put(uploadUrl, file, {
|
||||
headers: {
|
||||
'Content-Type': fileType,
|
||||
},
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
throw new Error('File upload to S3 failed');
|
||||
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
setUploadError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
await uploadFileWithProgress({ file, setUploadProgressPercent });
|
||||
formElement.reset();
|
||||
allUserFiles.refetch();
|
||||
} catch (error) {
|
||||
alert('Error uploading file. Please try again');
|
||||
console.error('Error uploading file', error);
|
||||
console.error('Error uploading file:', error);
|
||||
setUploadError({
|
||||
message:
|
||||
error instanceof Error ? error.message : 'An unexpected error occurred while uploading the file.',
|
||||
code: 'UPLOAD_FAILED',
|
||||
});
|
||||
} finally {
|
||||
setUploadProgressPercent(0);
|
||||
}
|
||||
};
|
||||
|
||||
@ -72,45 +94,66 @@ export default function FileUploadPage() {
|
||||
</h2>
|
||||
</div>
|
||||
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-white'>
|
||||
This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a lot of
|
||||
people asked for this feature, so here you go 🤝
|
||||
This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a
|
||||
lot of people asked for this feature, so here you go 🤝
|
||||
</p>
|
||||
<div className='my-8 border rounded-3xl border-gray-900/10 dark:border-gray-100/10'>
|
||||
<div className='space-y-10 my-10 py-8 px-4 mx-auto sm:max-w-lg'>
|
||||
<form onSubmit={handleUpload} className='flex flex-col gap-2'>
|
||||
<input
|
||||
type='file'
|
||||
id='file-upload'
|
||||
name='file-upload'
|
||||
accept='image/jpeg, image/png, .pdf, text/*'
|
||||
className='text-gray-600 '
|
||||
accept={ALLOWED_FILE_TYPES.join(',')}
|
||||
className='text-gray-600'
|
||||
onChange={() => setUploadError(null)}
|
||||
/>
|
||||
<button
|
||||
type='submit'
|
||||
className='min-w-[7rem] font-medium text-gray-800/90 bg-yellow-50 shadow-md ring-1 ring-inset ring-slate-200 py-2 px-4 rounded-md hover:bg-yellow-100 duration-200 ease-in-out focus:outline-none focus:shadow-none hover:shadow-none'
|
||||
disabled={uploadProgressPercent > 0}
|
||||
className='min-w-[7rem] relative font-medium text-gray-800/90 bg-yellow-50 shadow-md ring-1 ring-inset ring-slate-200 py-2 px-4 rounded-md hover:bg-yellow-100 duration-200 ease-in-out focus:outline-none focus:shadow-none hover:shadow-none disabled:cursor-progress'
|
||||
>
|
||||
Upload
|
||||
{uploadProgressPercent > 0 ? (
|
||||
<>
|
||||
<span>Uploading {uploadProgressPercent}%</span>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={uploadProgressPercent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="absolute bottom-0 left-0 h-1 bg-yellow-500 transition-all duration-300 ease-in-out rounded-b-md"
|
||||
style={{ width: `${uploadProgressPercent}%` }}
|
||||
></div>
|
||||
</>
|
||||
) : (
|
||||
'Upload'
|
||||
)}
|
||||
</button>
|
||||
{uploadError && <div className='text-red-500'>{uploadError.message}</div>}
|
||||
</form>
|
||||
<div className='border-b-2 border-gray-200 dark:border-gray-100/10'></div>
|
||||
<div className='space-y-4 col-span-full'>
|
||||
<h2 className='text-xl font-bold'>Uploaded Files</h2>
|
||||
{isFilesLoading && <p>Loading...</p>}
|
||||
{filesError && <p>Error: {filesError.message}</p>}
|
||||
{!!files && files.length > 0 ? (
|
||||
files.map((file: any) => (
|
||||
{allUserFiles.isLoading && <p>Loading...</p>}
|
||||
{allUserFiles.error && <p>Error: {allUserFiles.error.message}</p>}
|
||||
{!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? (
|
||||
allUserFiles.data.map((file: File) => (
|
||||
<div
|
||||
key={file.key}
|
||||
className={cn('flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3', {
|
||||
'opacity-70': file.key === fileToDownload && isDownloadUrlLoading,
|
||||
})}
|
||||
className={cn(
|
||||
'flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3',
|
||||
{
|
||||
'opacity-70': file.key === fileKeyForS3 && isDownloadUrlLoading,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<p>{file.name}</p>
|
||||
<button
|
||||
onClick={() => setFileToDownload(file.key)}
|
||||
disabled={file.key === fileToDownload && isDownloadUrlLoading}
|
||||
onClick={() => setFileKeyForS3(file.key)}
|
||||
disabled={file.key === fileKeyForS3 && isDownloadUrlLoading}
|
||||
className='min-w-[7rem] text-sm text-gray-800/90 bg-purple-50 shadow-md ring-1 ring-inset ring-slate-200 py-1 px-2 rounded-md hover:bg-purple-100 duration-200 ease-in-out focus:outline-none focus:shadow-none hover:shadow-none disabled:cursor-not-allowed'
|
||||
>
|
||||
{file.key === fileToDownload && isDownloadUrlLoading ? 'Loading...' : 'Download'}
|
||||
{file.key === fileKeyForS3 && isDownloadUrlLoading ? 'Loading...' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
|
54
template/app/src/file-upload/fileUploading.ts
Normal file
54
template/app/src/file-upload/fileUploading.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { createFile } from 'wasp/client/operations';
|
||||
import axios from 'axios';
|
||||
|
||||
interface FileUploadProgress {
|
||||
file: File;
|
||||
setUploadProgressPercent: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export interface FileUploadError {
|
||||
message: string;
|
||||
code: 'NO_FILE' | 'INVALID_FILE_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED';
|
||||
}
|
||||
|
||||
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // Set this to the max file size you want to allow (currently 5MB).
|
||||
export const ALLOWED_FILE_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'application/pdf',
|
||||
'text/*',
|
||||
'video/quicktime',
|
||||
'video/mp4',
|
||||
];
|
||||
|
||||
export async function uploadFileWithProgress({ file, setUploadProgressPercent }: FileUploadProgress) {
|
||||
const { uploadUrl } = await createFile({ fileType: file.type, name: file.name });
|
||||
return await axios.put(uploadUrl, file, {
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const percentage = Math.round((progressEvent.loaded / progressEvent.total) * 100);
|
||||
setUploadProgressPercent(percentage);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function validateFile(file: File): FileUploadError | null {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return {
|
||||
message: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
|
||||
code: 'FILE_TOO_LARGE',
|
||||
};
|
||||
}
|
||||
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
|
||||
return {
|
||||
message: `File type '${file.type}' is not supported.`,
|
||||
code: 'INVALID_FILE_TYPE',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
@ -6,10 +6,7 @@ import {
|
||||
type GetDownloadFileSignedURL,
|
||||
} from 'wasp/server/operations';
|
||||
|
||||
import {
|
||||
getUploadFileSignedURLFromS3,
|
||||
getDownloadFileSignedURLFromS3
|
||||
} from './s3Utils';
|
||||
import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils';
|
||||
|
||||
type FileDescription = {
|
||||
fileType: string;
|
||||
|
@ -36,4 +36,4 @@ export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) =
|
||||
};
|
||||
const command = new GetObjectCommand(s3Params);
|
||||
return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user