From ed7497a4d68cc80e1f3d7f83eeb7471aa8a2ea5c Mon Sep 17 00:00:00 2001 From: Stepan Usatiuk Date: Thu, 15 Oct 2020 15:59:58 +0300 Subject: [PATCH] simple photo viewer --- frontend/src/Home/Home.tsx | 5 ++ .../src/Photos/{Photos.scss => Overview.scss} | 0 frontend/src/Photos/Overview.tsx | 20 +++-- frontend/src/Photos/Photo.scss | 17 ++++ frontend/src/Photos/Photo.tsx | 79 +++++++++++++++++ frontend/src/Photos/PhotoCard.tsx | 11 +-- frontend/src/Photos/tests/Overview.test.tsx | 6 +- frontend/src/redux/api/photos.ts | 5 ++ frontend/src/redux/photos/actions.ts | 38 +++++++- frontend/src/redux/photos/reducer.ts | 86 ++++++++++++++++--- frontend/src/redux/photos/sagas.ts | 31 +++++++ 11 files changed, 269 insertions(+), 29 deletions(-) rename frontend/src/Photos/{Photos.scss => Overview.scss} (100%) create mode 100644 frontend/src/Photos/Photo.scss create mode 100644 frontend/src/Photos/Photo.tsx diff --git a/frontend/src/Home/Home.tsx b/frontend/src/Home/Home.tsx index fa69398..8f00a31 100644 --- a/frontend/src/Home/Home.tsx +++ b/frontend/src/Home/Home.tsx @@ -23,6 +23,7 @@ import { Overview } from "~Photos/Overview"; import { toggleDarkMode } from "~redux/localSettings/actions"; import { IAppState } from "~redux/reducers"; import { logoutUser } from "~redux/user/actions"; +import { Photo } from "~Photos/Photo"; export interface IHomeProps extends RouteComponentProps { user: IUserJSON | null; @@ -100,6 +101,10 @@ export class HomeComponent extends React.PureComponent { path="/account" component={Account} /> + diff --git a/frontend/src/Photos/Photos.scss b/frontend/src/Photos/Overview.scss similarity index 100% rename from frontend/src/Photos/Photos.scss rename to frontend/src/Photos/Overview.scss diff --git a/frontend/src/Photos/Overview.tsx b/frontend/src/Photos/Overview.tsx index 72068d5..1b744b1 100644 --- a/frontend/src/Photos/Overview.tsx +++ b/frontend/src/Photos/Overview.tsx @@ -1,4 +1,4 @@ -import "./Photos.scss"; +import "./Overview.scss"; import * as React from "react"; import { connect } from "react-redux"; import { Dispatch } from "redux"; @@ -12,9 +12,10 @@ import { UploadButton } from "./UploadButton"; export interface IOverviewComponentProps { photos: IPhotoReqJSON[] | null; - fetching: boolean; - fetchingError: string | null; - fetchingSpinner: boolean; + overviewLoaded: boolean; + overviewFetching: boolean; + overviewFetchingError: string | null; + overviewFetchingSpinner: boolean; fetchPhotos: () => void; } @@ -22,11 +23,11 @@ export interface IOverviewComponentProps { export const OverviewComponent: React.FunctionComponent = ( props, ) => { - if (!props.photos && !props.fetching) { + if (!props.overviewLoaded && !props.overviewFetching) { props.fetchPhotos(); } if (!props.photos) { - return ; + return ; } const photos = props.photos @@ -46,9 +47,10 @@ export const OverviewComponent: React.FunctionComponent function mapStateToProps(state: IAppState) { return { photos: state.photos.photos, - fetching: state.photos.fetching, - fetchingError: state.photos.fetchingError, - fetchingSpinner: state.photos.fetchingSpinner, + overviewLoaded: state.photos.overviewLoaded, + overviewFetching: state.photos.overviewFetching, + overviewFetchingError: state.photos.overviewFetchingError, + overviewFetchingSpinner: state.photos.overviewFetchingSpinner, }; } diff --git a/frontend/src/Photos/Photo.scss b/frontend/src/Photos/Photo.scss new file mode 100644 index 0000000..63a83cb --- /dev/null +++ b/frontend/src/Photos/Photo.scss @@ -0,0 +1,17 @@ +@import "~@blueprintjs/core/lib/scss/variables"; + +#photoView { + display: flex; + height: 100%; + justify-content: center; + align-items: center; + + #photo { + max-height: 100%; + max-width: 100%; + } +} + +.bp3-dark { + #photoView {} +} \ No newline at end of file diff --git a/frontend/src/Photos/Photo.tsx b/frontend/src/Photos/Photo.tsx new file mode 100644 index 0000000..4c63238 --- /dev/null +++ b/frontend/src/Photos/Photo.tsx @@ -0,0 +1,79 @@ +import "./Photo.scss"; +import * as React from "react"; +import { connect } from "react-redux"; +import { RouteComponentProps, withRouter } from "react-router"; +import { Dispatch } from "redux"; +import { IPhotoReqJSON } from "~../../src/entity/Photo"; +import { LoadingStub } from "~LoadingStub"; +import { getPhotoImgPath } from "~redux/api/photos"; +import { photoLoadStart } from "~redux/photos/actions"; +import { IPhotoState } from "~redux/photos/reducer"; +import { IAppState } from "~redux/reducers"; + +export interface IPhotoComponentProps extends RouteComponentProps { + photo: IPhotoReqJSON | undefined; + photoState: IPhotoState | undefined; + + fetchPhoto: (id: number) => void; +} + +function getId(props: RouteComponentProps) { + return parseInt((props.match?.params as { id: string }).id); +} + +export const PhotoComponent: React.FunctionComponent = ( + props, +) => { + const id = getId(props); + + if (!props.photo && !props.photoState?.fetching) { + console.log(props); + props.fetchPhoto(id); + } + if (!props.photo) { + return ; + } + + const fileExists = props.photo.uploaded; + + return ( + <> + {fileExists ? ( +
+ +
+ ) : ( +
Photo not uploaded yet
+ )} + + ); +}; + +function mapStateToProps(state: IAppState, props: RouteComponentProps) { + const id = getId(props); + let photo = undefined; + let photoState = undefined; + + if (state.photos.photos) { + photo = state.photos.photos.find((p) => p.id === id); + } + if (state.photos.photoStates[id]) { + photoState = state.photos.photoStates[id]; + } + return { + photo, + photoState, + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { fetchPhoto: (id: number) => dispatch(photoLoadStart(id)) }; +} + +export const Photo = withRouter( + connect(mapStateToProps, mapDispatchToProps)(PhotoComponent), +); diff --git a/frontend/src/Photos/PhotoCard.tsx b/frontend/src/Photos/PhotoCard.tsx index 0f03f35..455d3e2 100644 --- a/frontend/src/Photos/PhotoCard.tsx +++ b/frontend/src/Photos/PhotoCard.tsx @@ -13,8 +13,9 @@ import { Dispatch } from "redux"; import { photoDeleteCancel, photoDeleteStart } from "~redux/photos/actions"; import { connect } from "react-redux"; import { LoadingStub } from "~LoadingStub"; +import { RouteComponentProps, withRouter } from "react-router"; -export interface IPhotoCardComponentProps { +export interface IPhotoCardComponentProps extends RouteComponentProps { photo: IPhotoReqJSON; deletePhoto: (photo: IPhotoReqJSON) => void; @@ -47,11 +48,9 @@ export class PhotoCardComponent extends React.PureComponent< - this.props.history.push(`/docs/${this.props.doc.id}`) + this.props.history.push(`/photos/${this.props.photo.id}`) } - */ > {fileExists ? ( { return fetchJSONAuth("/photos/list", "GET"); } +export async function fetchPhoto(id: number): Promise { + return fetchJSONAuth(`/photos/byID/${id}`, "GET"); +} + export async function createPhoto( hash: string, size: string, diff --git a/frontend/src/redux/photos/actions.ts b/frontend/src/redux/photos/actions.ts index 535ad3f..94ec97c 100644 --- a/frontend/src/redux/photos/actions.ts +++ b/frontend/src/redux/photos/actions.ts @@ -10,6 +10,9 @@ export enum PhotoTypes { PHOTOS_LOAD_START = "PHOTOS_LOAD", PHOTOS_LOAD_SUCCESS = "PHOTOS_LOAD_SUCCESS", PHOTOS_LOAD_FAIL = "PHOTOS_LOAD_FAIL", + PHOTO_LOAD_START = "PHOTO_LOAD", + PHOTO_LOAD_SUCCESS = "PHOTO_LOAD_SUCCESS", + PHOTO_LOAD_FAIL = "PHOTO_LOAD_FAIL", PHOTOS_UPLOAD_START = "PHOTOS_UPLOAD", PHOTO_CREATE_SUCCESS = "PHOTO_CREATE_SUCCESS", PHOTO_CREATE_FAIL = "PHOTO_CREATE_FAIL", @@ -36,6 +39,22 @@ export interface IPhotosLoadFailAction extends Action { error: string; } +export interface IPhotoLoadStartAction extends Action { + type: PhotoTypes.PHOTO_LOAD_START; + id: number; +} + +export interface IPhotoLoadSuccessAction extends Action { + type: PhotoTypes.PHOTO_LOAD_SUCCESS; + photo: IPhotoReqJSON; +} + +export interface IPhotoLoadFailAction extends Action { + type: PhotoTypes.PHOTO_LOAD_FAIL; + id: number; + error: string; +} + export interface IPhotosUploadStartAction extends Action { type: PhotoTypes.PHOTOS_UPLOAD_START; files: FileList; @@ -92,6 +111,10 @@ export function photosLoadStart(): IPhotosLoadStartAction { return { type: PhotoTypes.PHOTOS_LOAD_START }; } +export function photoLoadStart(id: number): IPhotoLoadStartAction { + return { type: PhotoTypes.PHOTO_LOAD_START, id }; +} + export function photosUploadStart(files: FileList): IPhotosUploadStartAction { return { type: PhotoTypes.PHOTOS_UPLOAD_START, files }; } @@ -143,6 +166,16 @@ export function photosLoadFail(error: string): IPhotosLoadFailAction { return { type: PhotoTypes.PHOTOS_LOAD_FAIL, error }; } +export function photoLoadSuccess( + photo: IPhotoReqJSON, +): IPhotoLoadSuccessAction { + return { type: PhotoTypes.PHOTO_LOAD_SUCCESS, photo }; +} + +export function photoLoadFail(id:number,error: string): IPhotoLoadFailAction { + return { type: PhotoTypes.PHOTO_LOAD_FAIL,id, error }; +} + export function photoDeleteStart( photo: IPhotoReqJSON, ): IPhotoDeleteStartAction { @@ -183,4 +216,7 @@ export type PhotoAction = | IPhotoDeleteFailAction | IPhotoDeleteStartAction | IPhotoDeleteSuccessAction - | IPhotoDeleteCancelAction; + | IPhotoDeleteCancelAction + | IPhotoLoadFailAction + | IPhotoLoadStartAction + | IPhotoLoadSuccessAction; diff --git a/frontend/src/redux/photos/reducer.ts b/frontend/src/redux/photos/reducer.ts index f2d9255..1118d2c 100644 --- a/frontend/src/redux/photos/reducer.ts +++ b/frontend/src/redux/photos/reducer.ts @@ -3,20 +3,32 @@ import { IPhotoReqJSON } from "~../../src/entity/Photo"; import { UserAction, UserTypes } from "~redux/user/actions"; import { PhotoAction, PhotoTypes } from "./actions"; -export interface IPhotosState { - photos: IPhotoReqJSON[] | null; +export interface IPhotoState { fetching: boolean; fetchingError: string | null; - fetchingSpinner: boolean; +} + +export interface IPhotosState { + photos: IPhotoReqJSON[] | null; + + photoStates: Record; + + overviewFetching: boolean; + overviewLoaded: boolean; + overviewFetchingError: string | null; + overviewFetchingSpinner: boolean; deleteCache: Record; } const defaultPhotosState: IPhotosState = { photos: null, - fetching: false, - fetchingError: null, - fetchingSpinner: false, + overviewLoaded: false, + overviewFetching: false, + overviewFetchingError: null, + overviewFetchingSpinner: false, + + photoStates: {}, deleteCache: {}, }; @@ -31,15 +43,67 @@ export const photosReducer: Reducer = ( case PhotoTypes.PHOTOS_LOAD_START: return { ...defaultPhotosState, - fetching: true, - fetchingSpinner: false, + overviewFetching: true, + overviewFetchingSpinner: false, }; case PhotoTypes.PHOTOS_START_FETCHING_SPINNER: - return { ...state, fetchingSpinner: true }; + return { ...state, overviewFetchingSpinner: true }; case PhotoTypes.PHOTOS_LOAD_SUCCESS: - return { ...defaultPhotosState, photos: action.photos }; + return { + ...defaultPhotosState, + photos: action.photos, + overviewLoaded: true, + }; case PhotoTypes.PHOTOS_LOAD_FAIL: - return { ...defaultPhotosState, fetchingError: action.error }; + return { + ...defaultPhotosState, + overviewFetchingError: action.error, + }; + + case PhotoTypes.PHOTO_LOAD_START: { + const { photoStates } = state; + photoStates[action.id] = { + fetching: true, + fetchingError: null, + }; + return { + ...state, + photoStates, + }; + } + case PhotoTypes.PHOTO_LOAD_SUCCESS: { + const { photoStates } = state; + photoStates[action.photo.id] = { + fetching: false, + fetchingError: null, + }; + if (state.photos) { + const photos = state.photos; + const photosNoDup = photos.filter( + (p) => p.id !== action.photo.id, + ); + const updPhotos = [action.photo, ...photosNoDup]; + return { ...state, photos: updPhotos, photoStates }; + } else { + const photos = [action.photo]; + return { + ...state, + photos, + photoStates, + }; + } + } + case PhotoTypes.PHOTO_LOAD_FAIL: { + const { photoStates } = state; + photoStates[action.id] = { + fetching: false, + fetchingError: action.error, + }; + return { + ...state, + photoStates, + }; + } case PhotoTypes.PHOTO_CREATE_SUCCESS: if (state.photos) { const photos = state.photos; diff --git a/frontend/src/redux/photos/sagas.ts b/frontend/src/redux/photos/sagas.ts index 764126e..6fccb55 100644 --- a/frontend/src/redux/photos/sagas.ts +++ b/frontend/src/redux/photos/sagas.ts @@ -14,16 +14,20 @@ import * as SparkMD5 from "spark-md5"; import { createPhoto, deletePhoto, + fetchPhoto, fetchPhotosList, uploadPhoto, } from "~redux/api/photos"; import { IPhotoDeleteStartAction, + IPhotoLoadStartAction, IPhotosUploadStartAction, photoCreateFail, photoCreateSuccess, photoDeleteFail, photoDeleteSuccess, + photoLoadFail, + photoLoadSuccess, photosLoadFail, photosLoadSuccess, photosStartFetchingSpinner, @@ -130,6 +134,32 @@ function* photosLoad() { } } +function* photoLoad(action: IPhotoLoadStartAction) { + try { + //const spinner = yield fork(startSpinner); + + const { response, timeout } = yield race({ + response: call(fetchPhoto, action.id), + timeout: delay(10000), + }); + + //yield cancel(spinner); + + if (timeout) { + yield put(photoLoadFail(action.id, "Timeout")); + return; + } + if (response.data) { + const photo = response.data; + yield put(photoLoadSuccess(photo)); + } else { + yield put(photoLoadFail(action.id, response.error)); + } + } catch (e) { + yield put(photoLoadFail(action.id, "Internal error")); + } +} + function* photoUpload(f: File) { try { const hash = yield call(computeChecksumMd5, f); @@ -218,6 +248,7 @@ export function* photosSaga() { yield all([ takeLatest(PhotoTypes.PHOTOS_LOAD_START, photosLoad), takeLatest(PhotoTypes.PHOTOS_UPLOAD_START, photosUpload), + takeLatest(PhotoTypes.PHOTO_LOAD_START, photoLoad), takeEvery(PhotoTypes.PHOTO_DELETE_START, photoDelete), ]); }