mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-06-27 09:12:05 +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
|
--- template/app/src/file-upload/operations.ts
|
||||||
+++ opensaas-sh/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);
|
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 { 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() {
|
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(
|
const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } = useQuery(
|
||||||
getDownloadFileSignedURL,
|
getDownloadFileSignedURL,
|
||||||
{ key: fileToDownload },
|
{ key: fileKeyForS3 },
|
||||||
{ enabled: false }
|
{ enabled: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fileToDownload.length > 0) {
|
allUserFiles.refetch();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fileKeyForS3.length > 0) {
|
||||||
refetchDownloadUrl()
|
refetchDownloadUrl()
|
||||||
.then((urlQuery) => {
|
.then((urlQuery) => {
|
||||||
switch (urlQuery.status) {
|
switch (urlQuery.status) {
|
||||||
@ -28,38 +39,49 @@ export default function FileUploadPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setFileToDownload('');
|
setFileKeyForS3('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [fileToDownload]);
|
}, [fileKeyForS3]);
|
||||||
|
|
||||||
const handleUpload = async (e: FormEvent<HTMLFormElement>) => {
|
const handleUpload = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
try {
|
try {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.target as HTMLFormElement);
|
|
||||||
const file = formData.get('file-upload') as File;
|
const formElement = e.target;
|
||||||
if (!file || !file.name || !file.type) {
|
if (!(formElement instanceof HTMLFormElement)) {
|
||||||
throw new Error('No file selected');
|
throw new Error('Event target is not a form element');
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileType = file.type;
|
const formData = new FormData(formElement);
|
||||||
const name = file.name;
|
const file = formData.get('file-upload');
|
||||||
|
|
||||||
const { uploadUrl } = await createFile({ fileType, name });
|
if (!file || !(file instanceof File)) {
|
||||||
if (!uploadUrl) {
|
setUploadError({
|
||||||
throw new Error('Failed to get upload URL');
|
message: 'Please select a file to upload.',
|
||||||
}
|
code: 'NO_FILE',
|
||||||
const res = await axios.put(uploadUrl, file, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': fileType,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (res.status !== 200) {
|
return;
|
||||||
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) {
|
} 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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-white'>
|
<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
|
This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a
|
||||||
people asked for this feature, so here you go 🤝
|
lot of people asked for this feature, so here you go 🤝
|
||||||
</p>
|
</p>
|
||||||
<div className='my-8 border rounded-3xl border-gray-900/10 dark:border-gray-100/10'>
|
<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'>
|
<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'>
|
<form onSubmit={handleUpload} className='flex flex-col gap-2'>
|
||||||
<input
|
<input
|
||||||
type='file'
|
type='file'
|
||||||
|
id='file-upload'
|
||||||
name='file-upload'
|
name='file-upload'
|
||||||
accept='image/jpeg, image/png, .pdf, text/*'
|
accept={ALLOWED_FILE_TYPES.join(',')}
|
||||||
className='text-gray-600'
|
className='text-gray-600'
|
||||||
|
onChange={() => setUploadError(null)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type='submit'
|
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>
|
</button>
|
||||||
|
{uploadError && <div className='text-red-500'>{uploadError.message}</div>}
|
||||||
</form>
|
</form>
|
||||||
<div className='border-b-2 border-gray-200 dark:border-gray-100/10'></div>
|
<div className='border-b-2 border-gray-200 dark:border-gray-100/10'></div>
|
||||||
<div className='space-y-4 col-span-full'>
|
<div className='space-y-4 col-span-full'>
|
||||||
<h2 className='text-xl font-bold'>Uploaded Files</h2>
|
<h2 className='text-xl font-bold'>Uploaded Files</h2>
|
||||||
{isFilesLoading && <p>Loading...</p>}
|
{allUserFiles.isLoading && <p>Loading...</p>}
|
||||||
{filesError && <p>Error: {filesError.message}</p>}
|
{allUserFiles.error && <p>Error: {allUserFiles.error.message}</p>}
|
||||||
{!!files && files.length > 0 ? (
|
{!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? (
|
||||||
files.map((file: any) => (
|
allUserFiles.data.map((file: File) => (
|
||||||
<div
|
<div
|
||||||
key={file.key}
|
key={file.key}
|
||||||
className={cn('flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3', {
|
className={cn(
|
||||||
'opacity-70': file.key === fileToDownload && isDownloadUrlLoading,
|
'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>
|
<p>{file.name}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFileToDownload(file.key)}
|
onClick={() => setFileKeyForS3(file.key)}
|
||||||
disabled={file.key === fileToDownload && isDownloadUrlLoading}
|
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'
|
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>
|
</button>
|
||||||
</div>
|
</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,
|
type GetDownloadFileSignedURL,
|
||||||
} from 'wasp/server/operations';
|
} from 'wasp/server/operations';
|
||||||
|
|
||||||
import {
|
import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils';
|
||||||
getUploadFileSignedURLFromS3,
|
|
||||||
getDownloadFileSignedURLFromS3
|
|
||||||
} from './s3Utils';
|
|
||||||
|
|
||||||
type FileDescription = {
|
type FileDescription = {
|
||||||
fileType: string;
|
fileType: string;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user