1- import { createFile , useQuery , getAllFilesByUser , getDownloadFileSignedURL } from 'wasp/client/operations' ;
2- import axios from 'axios' ;
3- import { useState , useEffect , FormEvent } from 'react' ;
41import { cn } from '../client/cn' ;
2+ import { useState , useEffect , FormEvent } from 'react' ;
3+ import type { File } from 'wasp/entities' ;
4+ import { useQuery , getAllFilesByUser , getDownloadFileSignedURL } from 'wasp/client/operations' ;
5+ import { type FileUploadError , uploadFileWithProgress , validateFile , ALLOWED_FILE_TYPES } from './fileUploading' ;
56
67export default function FileUploadPage ( ) {
7- const [ fileToDownload , setFileToDownload ] = useState < string > ( '' ) ;
8+ const [ fileKeyForS3 , setFileKeyForS3 ] = useState < File [ 'key' ] > ( '' ) ;
9+ const [ uploadProgressPercent , setUploadProgressPercent ] = useState < number > ( 0 ) ;
10+ const [ uploadError , setUploadError ] = useState < FileUploadError | null > ( null ) ;
811
9- const { data : files , error : filesError , isLoading : isFilesLoading } = useQuery ( getAllFilesByUser ) ;
12+ const allUserFiles = useQuery ( getAllFilesByUser , undefined , {
13+ // We disable automatic refetching because otherwise files would be refetched after `createFile` is called and the S3 URL is returned,
14+ // which happens before the file is actually fully uploaded. Instead, we manually (re)fetch on mount and after the upload is complete.
15+ enabled : false ,
16+ } ) ;
1017 const { isLoading : isDownloadUrlLoading , refetch : refetchDownloadUrl } = useQuery (
1118 getDownloadFileSignedURL ,
12- { key : fileToDownload } ,
19+ { key : fileKeyForS3 } ,
1320 { enabled : false }
1421 ) ;
1522
1623 useEffect ( ( ) => {
17- if ( fileToDownload . length > 0 ) {
24+ allUserFiles . refetch ( ) ;
25+ } , [ ] ) ;
26+
27+ useEffect ( ( ) => {
28+ if ( fileKeyForS3 . length > 0 ) {
1829 refetchDownloadUrl ( )
1930 . then ( ( urlQuery ) => {
2031 switch ( urlQuery . status ) {
@@ -28,38 +39,49 @@ export default function FileUploadPage() {
2839 }
2940 } )
3041 . finally ( ( ) => {
31- setFileToDownload ( '' ) ;
42+ setFileKeyForS3 ( '' ) ;
3243 } ) ;
3344 }
34- } , [ fileToDownload ] ) ;
45+ } , [ fileKeyForS3 ] ) ;
3546
3647 const handleUpload = async ( e : FormEvent < HTMLFormElement > ) => {
3748 try {
3849 e . preventDefault ( ) ;
39- const formData = new FormData ( e . target as HTMLFormElement ) ;
40- const file = formData . get ( 'file-upload' ) as File ;
41- if ( ! file || ! file . name || ! file . type ) {
42- throw new Error ( 'No file selected ' ) ;
50+
51+ const formElement = e . target ;
52+ if ( ! ( formElement instanceof HTMLFormElement ) ) {
53+ throw new Error ( 'Event target is not a form element ' ) ;
4354 }
4455
45- const fileType = file . type ;
46- const name = file . name ;
56+ const formData = new FormData ( formElement ) ;
57+ const file = formData . get ( 'file-upload' ) ;
4758
48- const { uploadUrl } = await createFile ( { fileType, name } ) ;
49- if ( ! uploadUrl ) {
50- throw new Error ( 'Failed to get upload URL' ) ;
59+ if ( ! file || ! ( file instanceof File ) ) {
60+ setUploadError ( {
61+ message : 'Please select a file to upload.' ,
62+ code : 'NO_FILE' ,
63+ } ) ;
64+ return ;
5165 }
52- const res = await axios . put ( uploadUrl , file , {
53- headers : {
54- 'Content-Type' : fileType ,
55- } ,
56- } ) ;
57- if ( res . status !== 200 ) {
58- throw new Error ( 'File upload to S3 failed' ) ;
66+
67+ const validationError = validateFile ( file ) ;
68+ if ( validationError ) {
69+ setUploadError ( validationError ) ;
70+ return ;
5971 }
72+
73+ await uploadFileWithProgress ( { file, setUploadProgressPercent } ) ;
74+ formElement . reset ( ) ;
75+ allUserFiles . refetch ( ) ;
6076 } catch ( error ) {
61- alert ( 'Error uploading file. Please try again' ) ;
62- console . error ( 'Error uploading file' , error ) ;
77+ console . error ( 'Error uploading file:' , error ) ;
78+ setUploadError ( {
79+ message :
80+ error instanceof Error ? error . message : 'An unexpected error occurred while uploading the file.' ,
81+ code : 'UPLOAD_FAILED' ,
82+ } ) ;
83+ } finally {
84+ setUploadProgressPercent ( 0 ) ;
6385 }
6486 } ;
6587
@@ -72,45 +94,66 @@ export default function FileUploadPage() {
7294 </ h2 >
7395 </ div >
7496 < p className = 'mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-white' >
75- This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a lot of
76- people asked for this feature, so here you go 🤝
97+ This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a
98+ lot of people asked for this feature, so here you go 🤝
7799 </ p >
78100 < div className = 'my-8 border rounded-3xl border-gray-900/10 dark:border-gray-100/10' >
79101 < div className = 'space-y-10 my-10 py-8 px-4 mx-auto sm:max-w-lg' >
80102 < form onSubmit = { handleUpload } className = 'flex flex-col gap-2' >
81103 < input
82104 type = 'file'
105+ id = 'file-upload'
83106 name = 'file-upload'
84- accept = 'image/jpeg, image/png, .pdf, text/*'
85- className = 'text-gray-600 '
107+ accept = { ALLOWED_FILE_TYPES . join ( ',' ) }
108+ className = 'text-gray-600'
109+ onChange = { ( ) => setUploadError ( null ) }
86110 />
87111 < button
88112 type = 'submit'
89- 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'
113+ disabled = { uploadProgressPercent > 0 }
114+ 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'
90115 >
91- Upload
116+ { uploadProgressPercent > 0 ? (
117+ < >
118+ < span > Uploading { uploadProgressPercent } %</ span >
119+ < div
120+ role = "progressbar"
121+ aria-valuenow = { uploadProgressPercent }
122+ aria-valuemin = { 0 }
123+ aria-valuemax = { 100 }
124+ className = "absolute bottom-0 left-0 h-1 bg-yellow-500 transition-all duration-300 ease-in-out rounded-b-md"
125+ style = { { width : `${ uploadProgressPercent } %` } }
126+ > </ div >
127+ </ >
128+ ) : (
129+ 'Upload'
130+ ) }
92131 </ button >
132+ { uploadError && < div className = 'text-red-500' > { uploadError . message } </ div > }
93133 </ form >
94134 < div className = 'border-b-2 border-gray-200 dark:border-gray-100/10' > </ div >
95135 < div className = 'space-y-4 col-span-full' >
96136 < h2 className = 'text-xl font-bold' > Uploaded Files</ h2 >
97- { isFilesLoading && < p > Loading...</ p > }
98- { filesError && < p > Error: { filesError . message } </ p > }
99- { ! ! files && files . length > 0 ? (
100- files . map ( ( file : any ) => (
137+ { allUserFiles . isLoading && < p > Loading...</ p > }
138+ { allUserFiles . error && < p > Error: { allUserFiles . error . message } </ p > }
139+ { ! ! allUserFiles . data && allUserFiles . data . length > 0 && ! allUserFiles . isLoading ? (
140+ allUserFiles . data . map ( ( file : File ) => (
101141 < div
102142 key = { file . key }
103- className = { cn ( 'flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3' , {
104- 'opacity-70' : file . key === fileToDownload && isDownloadUrlLoading ,
105- } ) }
143+ className = { cn (
144+ 'flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3' ,
145+ {
146+ 'opacity-70' : file . key === fileKeyForS3 && isDownloadUrlLoading ,
147+ }
148+ ) }
106149 >
107150 < p > { file . name } </ p >
108151 < button
109- onClick = { ( ) => setFileToDownload ( file . key ) }
110- disabled = { file . key === fileToDownload && isDownloadUrlLoading }
152+ onClick = { ( ) => setFileKeyForS3 ( file . key ) }
153+ disabled = { file . key === fileKeyForS3 && isDownloadUrlLoading }
111154 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'
112155 >
113- { file . key === fileToDownload && isDownloadUrlLoading ? 'Loading...' : 'Download' }
156+ { file . key === fileKeyForS3 && isDownloadUrlLoading ? 'Loading...' : 'Download' }
114157 </ button >
115158 </ div >
116159 ) )
0 commit comments