diff --git a/components/jobs-list/jobs-list.jsx b/components/jobs-list/jobs-list.jsx index 545dc00..65d138b 100644 --- a/components/jobs-list/jobs-list.jsx +++ b/components/jobs-list/jobs-list.jsx @@ -10,6 +10,7 @@ import { createStructuredSelector } from "reselect"; import { QUERY_ALL_ACTIVE_JOBS } from "../../graphql/jobs.queries"; import { selectBodyshop } from "../../redux/user/user.selectors"; //import ErrorDisplay from "../error-display/error-display.component"; +import UploadProgress from "../upload-progress/upload-progress"; import JobListItem from "./job-list-item"; const mapStateToProps = createStructuredSelector({ @@ -67,6 +68,7 @@ export function JobListComponent({ bodyshop }) { return ( + diff --git a/components/upload-progress/upload-progress.jsx b/components/upload-progress/upload-progress.jsx new file mode 100644 index 0000000..63a730f --- /dev/null +++ b/components/upload-progress/upload-progress.jsx @@ -0,0 +1,120 @@ +import { StyleSheet, Text, View } from "react-native"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectPhotos } from "../../redux/photos/photos.selectors"; + +const mapStateToProps = createStructuredSelector({ + photos: selectPhotos, +}); + +export default connect(mapStateToProps, null)(UploadProgress); + +export function UploadProgress({ photos }) { + if (photos?.length === 0) return null; + return ( + + Upload Progress. + {JSON.stringify(photos)} + {/* + + {Object.keys(progress.files).map((key) => ( + + + {progress.files[key].filename} + + + + + {`${formatBytes( + progress.files[key].loaded / + (((progress.files[key].uploadEnd || new Date()) - + progress.files[key].uploadStart) / + 1000) + )}/sec`} + {progress.files[key].percent === 1 && ( + <> + + Processing... + + )} + + + + ))} + + {progress.statusText ? ( + <> + + {progress.statusText} + + + ) : ( + <> + {`${progress.totalFilesCompleted} of ${progress.totalFiles} uploaded.`} + {`${formatBytes(progress.totalUploaded)} of ${formatBytes( + progress.totalToUpload + )} uploaded.`} + + )} + + + + */} + + ); +} +const styles = StyleSheet.create({ + modalContainer: { + display: "flex", + flex: 1, + justifyContent: "center", + }, + modal: { + //flex: 1, + display: "flex", + marginLeft: 20, + marginRight: 20, + backgroundColor: "white", + borderRadius: 20, + padding: 18, + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + centeredView: { + justifyContent: "center", + alignItems: "center", + marginTop: 22, + }, + progressItem: { + display: "flex", + flexDirection: "row", + alignItems: "center", + marginBottom: 12, + marginLeft: 12, + marginRight: 12, + }, + progressText: { + flex: 1, + }, + progressBarContainer: { + flex: 3, + marginLeft: 12, + marginRight: 12, + }, +}); diff --git a/redux/photos/photos.actions.js b/redux/photos/photos.actions.js index 1e3f20d..a6ff986 100644 --- a/redux/photos/photos.actions.js +++ b/redux/photos/photos.actions.js @@ -8,4 +8,14 @@ export const openImagePicker = (jobid) => ({ export const mediaUploadStart = (imagePickerResult) => ({ type: PhotosActionTypes.MEDIA_UPLOAD_START, payload: imagePickerResult, -}) \ No newline at end of file +}) + +export const mediaUploadProgressOne = (progressUpdate) => ({ + type: PhotosActionTypes.MEDIA_UPLOAD_PROGRESS_UPDATE_ONE, + payload: progressUpdate, +}); + +export const mediaUploadFailure = (error) => ({ + type: PhotosActionTypes.MEDIA_UPLOAD_FAILURE, + payload: error, +}); \ No newline at end of file diff --git a/redux/photos/photos.reducer.js b/redux/photos/photos.reducer.js index 19610e8..3e92fb6 100644 --- a/redux/photos/photos.reducer.js +++ b/redux/photos/photos.reducer.js @@ -4,6 +4,8 @@ const INITIAL_STATE = { photos: [], uploadInProgress: false, uploadError: null, + jobid: null, + progress: {} }; const photosReducer = (state = INITIAL_STATE, action) => { @@ -11,9 +13,22 @@ const photosReducer = (state = INITIAL_STATE, action) => { case PhotosActionTypes.MEDIA_UPLOAD_START: return { ...state, - photos: action.payload, + photos: action.payload.photos, + jobid: action.payload.jobid, uploadInProgress: true, uploadError: null, + progress: {} + }; + case PhotosActionTypes.MEDIA_UPLOAD_FAILURE: + return { + ...state, + uploadInProgress: false, + uploadError: action.payload, + }; + case PhotosActionTypes.MEDIA_UPLOAD_PROGRESS_UPDATE_ONE: + return { + ...state, + progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], ...action.payload } } }; default: diff --git a/redux/photos/photos.sagas.js b/redux/photos/photos.sagas.js index 5e2195c..ed76b57 100644 --- a/redux/photos/photos.sagas.js +++ b/redux/photos/photos.sagas.js @@ -1,14 +1,31 @@ +import axios from "axios"; import Constants from "expo-constants"; import * as ImagePicker from "expo-image-picker"; -import { all, call, put, select, takeLatest } from "redux-saga/effects"; +import { END, eventChannel } from 'redux-saga'; +import { all, call, delay, fork, put, select, take, takeEvery, takeLatest } from "redux-saga/effects"; +import env from "../../env"; +import { axiosAuthInterceptorId } from "../../util/CleanAxios"; +import { fetchImageFromUri, replaceAccents } from '../../util/uploadUtils'; import { selectBodyshop } from "../user/user.selectors"; -import { mediaUploadStart } from "./photos.actions"; +import { + mediaUploadComplete, + mediaUploadFailure, + mediaUploadProgressOne, + mediaUploadStart +} from "./photos.actions"; import PhotosActionTypes from "./photos.types"; +//Required to prevent headers from getting set and rejected from Cloudinary. +let cleanAxios = axios.create(); +cleanAxios.interceptors.request.eject(axiosAuthInterceptorId); + + + export function* onOpenImagePicker() { yield takeLatest(PhotosActionTypes.OPEN_IMAGE_PICKER, openImagePickerAction); } -export function* openImagePickerAction(jobid) { + +export function* openImagePickerAction({ payload: jobid }) { try { if (Constants.platform.ios) { const cameraRollStatus = @@ -29,37 +46,214 @@ export function* openImagePickerAction(jobid) { allowsMultipleSelection: true, }); if (!(result.canceled)) { - yield put(mediaUploadStart(result.assets)); + yield put(mediaUploadStart({ photos: result.assets, jobid })); } } catch (error) { console.log("Saga Error: open Picker", error); } } + export function* onMediaUploadStart() { yield takeLatest(PhotosActionTypes.MEDIA_UPLOAD_START, mediaUploadStartAction); } -export function* mediaUploadStartAction(photos) { + +export function* mediaUploadStartAction({ payload: { photos, jobid } }) { try { - console.log("Got to the Photo Saga.", photos); + console.log("Starting upload for", photos.length, "photos"); - console.log("upload", photos) - - //get bodyshop state const bodyshop = yield select(selectBodyshop); - if (bodyshop.uselocalmediaserver) { - //upload to LMS - } else { - //Upload to img proxy - } + // Process photos in batches to avoid overwhelming the system + const batchSize = 3; // Upload 3 photos concurrently + const batches = []; + + for (let i = 0; i < photos.length; i += batchSize) { + batches.push(photos.slice(i, i + batchSize)); + } + // Process each batch sequentially, but photos within batch concurrently + for (const batch of batches) { + const uploadTasks = batch.map((photo, index) => + fork(uploadSinglePhoto, photo, bodyshop, index, jobid) + ); + // Wait for current batch to complete before starting next batch + yield all(uploadTasks); + // Small delay between batches to prevent overwhelming the server + yield delay(100); + } + yield put(mediaUploadComplete()); } catch (error) { - console.log("Saga Error: open upload", error); + console.log("Saga Error: upload start", error); + yield put(mediaUploadFailure(error.message)); } } +function* uploadSinglePhoto(photo, bodyshop, index, jobid) { + try { + yield put(mediaUploadProgressOne({ ...photo, status: 'starting', progress: 0 })); + const photoBlob = yield fetchImageFromUri(photo.uri); //has a name, and type. + console.log("*** ~ uploadSinglePhoto ~ photoBlob.name.:", photoBlob.data.name); + const extension = photoBlob.data?.name?.split('.').pop(); + + const key = `${bodyshop.id}/${jobid}/${replaceAccents( + photoBlob.data.name + ).replace(/[^A-Z0-9]+/gi, "_")}-${new Date().getTime()}.${extension}` + + if (bodyshop.uselocalmediaserver) { + yield call(uploadToLocalMediaServer, photo, photoBlob, key, bodyshop, jobid); + } else { + yield call(uploadToImageProxy, photo, photoBlob, key, bodyshop, jobid); + } + + // yield put(mediaUploadSuccess({ photoId, photo })); + + } catch (error) { + console.log(`Upload failed for photo ${photo.assetId}:`, error); + yield put(mediaUploadFailure({ ...photo, status: "error", error: error.message })); + } +} + +function* uploadToLocalMediaServer(photo, key) { + try { + // yield put(mediaUploadProgress({ photoId, status: 'uploading', progress: 25 })); + + // const formData = new FormData(); + // formData.append('file', { + // uri: photo.uri, + // type: photo.type || 'image/jpeg', + // name: photo.fileName || `photo_${Date.now()}.jpg`, + // }); + + // yield put(mediaUploadProgress({ photoId, status: 'uploading', progress: 50 })); + + // const response = yield call(fetch, 'YOUR_LOCAL_MEDIA_SERVER_ENDPOINT', { + // method: 'POST', + // body: formData, + // headers: { + // 'Content-Type': 'multipart/form-data', + // }, + // }); + + // yield put(mediaUploadProgress({ photoId, status: 'uploading', progress: 75 })); + + // if (!response.ok) { + // throw new Error(`Upload failed: ${response.status}`); + // } + + // const result = yield call([response, 'json']); + // yield put(mediaUploadProgress({ photoId, status: 'completed', progress: 100 })); + + // return result; + + } catch (error) { + throw new Error(`Local media server upload failed: ${error.message}`); + } +} + +function* uploadToImageProxy(photo, photoBlob, key, bodyshop, jobid) { + try { + yield put(mediaUploadProgressOne({ ...photo, status: 'uploading', })); + //Get the signed url allowing us to PUT to S3. + const signedURLResponse = yield call(axios.post, + `${env.API_URL}/media/imgproxy/sign`, + { + filenames: [key], + bodyshopid: bodyshop.id, + jobid, + } + ); + + if (signedURLResponse.status !== 200) { + console.log("Error Getting Signed URL", signedURLResponse.statusText); + throw new Error(`Error getting signed URL : ${signedURLResponse.statusText}`); + } + const { presignedUrl: preSignedUploadUrlToS3, key: s3Key } = + signedURLResponse.data.signedUrls[0]; + console.log("*** ~ uploadToImageProxy ~ s3Key:", s3Key); + console.log("*** ~ uploadToImageProxy ~ presignedUrl:", preSignedUploadUrlToS3); + + // Create an eventChannel to bridge imperative upload progress events into the saga world + const uploadChannel = yield call(createAxiosUploadChannel, { + url: preSignedUploadUrlToS3, + data: photoBlob, + method: 'put', + headers: { + 'Content-Type': photoBlob.type, + 'Content-Length': photoBlob.size, + }, + }); + + try { + while (true) { + const { progress, error, success } = yield take(uploadChannel); + if (progress != null) { + yield put(mediaUploadProgressOne({ ...photo, progress })); + } else if (error) { + throw error; + } else if (success) { + break; + } + } + } finally { + uploadChannel.close(); + } + + } catch (error) { + console.log("Error uploading to image proxy", JSON.stringify(error)); + throw new Error(`Image proxy upload failed: ${error.message}`); + } +} + +// Helper: turn axios upload progress into an eventChannel so we can yield put from saga +function createAxiosUploadChannel({ url, data, method = 'put', headers }) { + return eventChannel(emitter => { + const controller = new AbortController(); + cleanAxios({ + method, + url, + data, + headers, + signal: controller.signal, + // NOTE: cannot yield here, so we emit events and handle them in saga loop + onUploadProgress: (e) => { + if (e && e.total) { + emitter({ progress: e.loaded / e.total }); + } + } + }).then(() => { + emitter({ success: true }); + emitter(END); + }).catch(err => { + emitter({ error: err }); + emitter(END); + }); + + // Unsubscribe / cancellation logic + return () => { + controller.abort() + }; + }); +} + +// Handle cancellation of uploads +export function* onCancelUpload() { + yield takeEvery(PhotosActionTypes.CANCEL_UPLOAD, cancelUploadAction); +} + +function* cancelUploadAction({ payload: photoId }) { + // const task = uploadTasks.get(photoId); + // if (task) { + // yield cancel(task); + // uploadTasks.delete(photoId); + // yield put(mediaUploadFailure({ photoId, error: 'Upload cancelled' })); + // } +} export function* photosSagas() { - yield all([call(onOpenImagePicker), call(onMediaUploadStart)]); -} + yield all([ + call(onOpenImagePicker), + call(onMediaUploadStart), + //call(onCancelUpload) + ]); +} \ No newline at end of file diff --git a/redux/photos/photos.types.js b/redux/photos/photos.types.js index 8361795..ebede2c 100644 --- a/redux/photos/photos.types.js +++ b/redux/photos/photos.types.js @@ -3,6 +3,6 @@ const PhotosActionTypes = { MEDIA_UPLOAD_START: "MEDIA_UPLOAD_START", MEDIUA_UPLOAD_SUCCESS: "MEDIA_UPLOAD_SUCCESS", MEDIA_UPLOAD_FAILURE: "MEDIA_UPLOAD_FAILURE", - MEDIA_UPLOAD_PROGRESS_UPDATE: "MEDIA_UPLOAD_PROGRESS_UPDATE", + MEDIA_UPLOAD_PROGRESS_UPDATE_ONE: "MEDIA_UPLOAD_PROGRESS_UPDATE_ONE", }; export default PhotosActionTypes; diff --git a/util/uploadUtils.js b/util/uploadUtils.js new file mode 100644 index 0000000..363d0bd --- /dev/null +++ b/util/uploadUtils.js @@ -0,0 +1,51 @@ +export function replaceAccents(str) { + // Verifies if the String has accents and replace them + if (str.search(/[\xC0-\xFF]/g) > -1) { + str = str + .replace(/[\xC0-\xC5]/g, "A") + .replace(/[\xC6]/g, "AE") + .replace(/[\xC7]/g, "C") + .replace(/[\xC8-\xCB]/g, "E") + .replace(/[\xCC-\xCF]/g, "I") + .replace(/[\xD0]/g, "D") + .replace(/[\xD1]/g, "N") + .replace(/[\xD2-\xD6\xD8]/g, "O") + .replace(/[\xD9-\xDC]/g, "U") + .replace(/[\xDD]/g, "Y") + .replace(/[\xDE]/g, "P") + .replace(/[\xE0-\xE5]/g, "a") + .replace(/[\xE6]/g, "ae") + .replace(/[\xE7]/g, "c") + .replace(/[\xE8-\xEB]/g, "e") + .replace(/[\xEC-\xEF]/g, "i") + .replace(/[\xF1]/g, "n") + .replace(/[\xF2-\xF6\xF8]/g, "o") + .replace(/[\xF9-\xFC]/g, "u") + .replace(/[\xFE]/g, "p") + .replace(/[\xFD\xFF]/g, "y"); + } + return str; +} + +export function formatBytes(a, b = 2) { + if (0 === a || !a || isNaN(a)) return "0 Bytes"; + const c = 0 > b ? 0 : b, + d = Math.floor(Math.log(a) / Math.log(1024)); + + const parsedFloat = parseFloat((a / Math.pow(1024, d)).toFixed(c)) + if (isNaN(parsedFloat)) { + return "0 Bytes"; + } + return ( + parsedFloat + + " " + + ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"][d] + ); +} + + +export async function fetchImageFromUri(uri) { + const response = await fetch(uri); + const blob = await response.blob(); + return blob; +}; \ No newline at end of file