simple photo viewer

This commit is contained in:
2020-10-15 15:59:58 +03:00
committed by Stepan Usatiuk
parent c3e12aa250
commit ed7497a4d6
11 changed files with 269 additions and 29 deletions

View File

@@ -23,6 +23,7 @@ import { Overview } from "~Photos/Overview";
import { toggleDarkMode } from "~redux/localSettings/actions"; import { toggleDarkMode } from "~redux/localSettings/actions";
import { IAppState } from "~redux/reducers"; import { IAppState } from "~redux/reducers";
import { logoutUser } from "~redux/user/actions"; import { logoutUser } from "~redux/user/actions";
import { Photo } from "~Photos/Photo";
export interface IHomeProps extends RouteComponentProps { export interface IHomeProps extends RouteComponentProps {
user: IUserJSON | null; user: IUserJSON | null;
@@ -100,6 +101,10 @@ export class HomeComponent extends React.PureComponent<IHomeProps> {
path="/account" path="/account"
component={Account} component={Account}
/> />
<Route
path="/photos/:id"
component={Photo}
/>
<Route path="/" component={Overview} /> <Route path="/" component={Overview} />
</Switch> </Switch>
</animated.div> </animated.div>

View File

@@ -1,4 +1,4 @@
import "./Photos.scss"; import "./Overview.scss";
import * as React from "react"; import * as React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
@@ -12,9 +12,10 @@ import { UploadButton } from "./UploadButton";
export interface IOverviewComponentProps { export interface IOverviewComponentProps {
photos: IPhotoReqJSON[] | null; photos: IPhotoReqJSON[] | null;
fetching: boolean; overviewLoaded: boolean;
fetchingError: string | null; overviewFetching: boolean;
fetchingSpinner: boolean; overviewFetchingError: string | null;
overviewFetchingSpinner: boolean;
fetchPhotos: () => void; fetchPhotos: () => void;
} }
@@ -22,11 +23,11 @@ export interface IOverviewComponentProps {
export const OverviewComponent: React.FunctionComponent<IOverviewComponentProps> = ( export const OverviewComponent: React.FunctionComponent<IOverviewComponentProps> = (
props, props,
) => { ) => {
if (!props.photos && !props.fetching) { if (!props.overviewLoaded && !props.overviewFetching) {
props.fetchPhotos(); props.fetchPhotos();
} }
if (!props.photos) { if (!props.photos) {
return <LoadingStub spinner={props.fetchingSpinner} />; return <LoadingStub spinner={props.overviewFetchingSpinner} />;
} }
const photos = props.photos const photos = props.photos
@@ -46,9 +47,10 @@ export const OverviewComponent: React.FunctionComponent<IOverviewComponentProps>
function mapStateToProps(state: IAppState) { function mapStateToProps(state: IAppState) {
return { return {
photos: state.photos.photos, photos: state.photos.photos,
fetching: state.photos.fetching, overviewLoaded: state.photos.overviewLoaded,
fetchingError: state.photos.fetchingError, overviewFetching: state.photos.overviewFetching,
fetchingSpinner: state.photos.fetchingSpinner, overviewFetchingError: state.photos.overviewFetchingError,
overviewFetchingSpinner: state.photos.overviewFetchingSpinner,
}; };
} }

View File

@@ -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 {}
}

View File

@@ -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<IPhotoComponentProps> = (
props,
) => {
const id = getId(props);
if (!props.photo && !props.photoState?.fetching) {
console.log(props);
props.fetchPhoto(id);
}
if (!props.photo) {
return <LoadingStub spinner={false} />;
}
const fileExists = props.photo.uploaded;
return (
<>
{fileExists ? (
<div id="photoView">
<img
id="photo"
loading="lazy"
src={getPhotoImgPath(props.photo)}
/>
</div>
) : (
<div>Photo not uploaded yet</div>
)}
</>
);
};
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),
);

View File

@@ -13,8 +13,9 @@ import { Dispatch } from "redux";
import { photoDeleteCancel, photoDeleteStart } from "~redux/photos/actions"; import { photoDeleteCancel, photoDeleteStart } from "~redux/photos/actions";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { LoadingStub } from "~LoadingStub"; import { LoadingStub } from "~LoadingStub";
import { RouteComponentProps, withRouter } from "react-router";
export interface IPhotoCardComponentProps { export interface IPhotoCardComponentProps extends RouteComponentProps {
photo: IPhotoReqJSON; photo: IPhotoReqJSON;
deletePhoto: (photo: IPhotoReqJSON) => void; deletePhoto: (photo: IPhotoReqJSON) => void;
@@ -47,11 +48,9 @@ export class PhotoCardComponent extends React.PureComponent<
<Card <Card
className="photoCard" className="photoCard"
interactive={true} interactive={true}
/*
onClick={() => onClick={() =>
this.props.history.push(`/docs/${this.props.doc.id}`) this.props.history.push(`/photos/${this.props.photo.id}`)
} }
*/
> >
{fileExists ? ( {fileExists ? (
<img <img
@@ -91,4 +90,6 @@ function mapDispatchToProps(dispatch: Dispatch) {
}; };
} }
export const PhotoCard = connect(null, mapDispatchToProps)(PhotoCardComponent); export const PhotoCard = withRouter(
connect(null, mapDispatchToProps)(PhotoCardComponent),
);

View File

@@ -11,9 +11,9 @@ const fetchPhotosFn = jest.fn();
const overviewComponentDefaultProps: IOverviewComponentProps = { const overviewComponentDefaultProps: IOverviewComponentProps = {
photos: null, photos: null,
fetching: false, overviewFetching: false,
fetchingError: null, overviewFetchingError: null,
fetchingSpinner: false, overviewFetchingSpinner: false,
fetchPhotos: fetchPhotosFn, fetchPhotos: fetchPhotosFn,
}; };

View File

@@ -1,6 +1,7 @@
import { IPhotoReqJSON } from "~../../src/entity/Photo"; import { IPhotoReqJSON } from "~../../src/entity/Photo";
import { import {
IPhotosByIDDeleteRespBody, IPhotosByIDDeleteRespBody,
IPhotosByIDGetRespBody,
IPhotosListRespBody, IPhotosListRespBody,
IPhotosNewRespBody, IPhotosNewRespBody,
IPhotosUploadRespBody, IPhotosUploadRespBody,
@@ -22,6 +23,10 @@ export async function fetchPhotosList(): Promise<IPhotosListRespBody> {
return fetchJSONAuth("/photos/list", "GET"); return fetchJSONAuth("/photos/list", "GET");
} }
export async function fetchPhoto(id: number): Promise<IPhotosByIDGetRespBody> {
return fetchJSONAuth(`/photos/byID/${id}`, "GET");
}
export async function createPhoto( export async function createPhoto(
hash: string, hash: string,
size: string, size: string,

View File

@@ -10,6 +10,9 @@ export enum PhotoTypes {
PHOTOS_LOAD_START = "PHOTOS_LOAD", PHOTOS_LOAD_START = "PHOTOS_LOAD",
PHOTOS_LOAD_SUCCESS = "PHOTOS_LOAD_SUCCESS", PHOTOS_LOAD_SUCCESS = "PHOTOS_LOAD_SUCCESS",
PHOTOS_LOAD_FAIL = "PHOTOS_LOAD_FAIL", 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", PHOTOS_UPLOAD_START = "PHOTOS_UPLOAD",
PHOTO_CREATE_SUCCESS = "PHOTO_CREATE_SUCCESS", PHOTO_CREATE_SUCCESS = "PHOTO_CREATE_SUCCESS",
PHOTO_CREATE_FAIL = "PHOTO_CREATE_FAIL", PHOTO_CREATE_FAIL = "PHOTO_CREATE_FAIL",
@@ -36,6 +39,22 @@ export interface IPhotosLoadFailAction extends Action {
error: string; 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 { export interface IPhotosUploadStartAction extends Action {
type: PhotoTypes.PHOTOS_UPLOAD_START; type: PhotoTypes.PHOTOS_UPLOAD_START;
files: FileList; files: FileList;
@@ -92,6 +111,10 @@ export function photosLoadStart(): IPhotosLoadStartAction {
return { type: PhotoTypes.PHOTOS_LOAD_START }; 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 { export function photosUploadStart(files: FileList): IPhotosUploadStartAction {
return { type: PhotoTypes.PHOTOS_UPLOAD_START, files }; return { type: PhotoTypes.PHOTOS_UPLOAD_START, files };
} }
@@ -143,6 +166,16 @@ export function photosLoadFail(error: string): IPhotosLoadFailAction {
return { type: PhotoTypes.PHOTOS_LOAD_FAIL, error }; 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( export function photoDeleteStart(
photo: IPhotoReqJSON, photo: IPhotoReqJSON,
): IPhotoDeleteStartAction { ): IPhotoDeleteStartAction {
@@ -183,4 +216,7 @@ export type PhotoAction =
| IPhotoDeleteFailAction | IPhotoDeleteFailAction
| IPhotoDeleteStartAction | IPhotoDeleteStartAction
| IPhotoDeleteSuccessAction | IPhotoDeleteSuccessAction
| IPhotoDeleteCancelAction; | IPhotoDeleteCancelAction
| IPhotoLoadFailAction
| IPhotoLoadStartAction
| IPhotoLoadSuccessAction;

View File

@@ -3,20 +3,32 @@ import { IPhotoReqJSON } from "~../../src/entity/Photo";
import { UserAction, UserTypes } from "~redux/user/actions"; import { UserAction, UserTypes } from "~redux/user/actions";
import { PhotoAction, PhotoTypes } from "./actions"; import { PhotoAction, PhotoTypes } from "./actions";
export interface IPhotosState { export interface IPhotoState {
photos: IPhotoReqJSON[] | null;
fetching: boolean; fetching: boolean;
fetchingError: string | null; fetchingError: string | null;
fetchingSpinner: boolean; }
export interface IPhotosState {
photos: IPhotoReqJSON[] | null;
photoStates: Record<number, IPhotoState>;
overviewFetching: boolean;
overviewLoaded: boolean;
overviewFetchingError: string | null;
overviewFetchingSpinner: boolean;
deleteCache: Record<number, IPhotoReqJSON>; deleteCache: Record<number, IPhotoReqJSON>;
} }
const defaultPhotosState: IPhotosState = { const defaultPhotosState: IPhotosState = {
photos: null, photos: null,
fetching: false, overviewLoaded: false,
fetchingError: null, overviewFetching: false,
fetchingSpinner: false, overviewFetchingError: null,
overviewFetchingSpinner: false,
photoStates: {},
deleteCache: {}, deleteCache: {},
}; };
@@ -31,15 +43,67 @@ export const photosReducer: Reducer<IPhotosState, PhotoAction> = (
case PhotoTypes.PHOTOS_LOAD_START: case PhotoTypes.PHOTOS_LOAD_START:
return { return {
...defaultPhotosState, ...defaultPhotosState,
fetching: true, overviewFetching: true,
fetchingSpinner: false, overviewFetchingSpinner: false,
}; };
case PhotoTypes.PHOTOS_START_FETCHING_SPINNER: case PhotoTypes.PHOTOS_START_FETCHING_SPINNER:
return { ...state, fetchingSpinner: true }; return { ...state, overviewFetchingSpinner: true };
case PhotoTypes.PHOTOS_LOAD_SUCCESS: case PhotoTypes.PHOTOS_LOAD_SUCCESS:
return { ...defaultPhotosState, photos: action.photos }; return {
...defaultPhotosState,
photos: action.photos,
overviewLoaded: true,
};
case PhotoTypes.PHOTOS_LOAD_FAIL: 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: case PhotoTypes.PHOTO_CREATE_SUCCESS:
if (state.photos) { if (state.photos) {
const photos = state.photos; const photos = state.photos;

View File

@@ -14,16 +14,20 @@ import * as SparkMD5 from "spark-md5";
import { import {
createPhoto, createPhoto,
deletePhoto, deletePhoto,
fetchPhoto,
fetchPhotosList, fetchPhotosList,
uploadPhoto, uploadPhoto,
} from "~redux/api/photos"; } from "~redux/api/photos";
import { import {
IPhotoDeleteStartAction, IPhotoDeleteStartAction,
IPhotoLoadStartAction,
IPhotosUploadStartAction, IPhotosUploadStartAction,
photoCreateFail, photoCreateFail,
photoCreateSuccess, photoCreateSuccess,
photoDeleteFail, photoDeleteFail,
photoDeleteSuccess, photoDeleteSuccess,
photoLoadFail,
photoLoadSuccess,
photosLoadFail, photosLoadFail,
photosLoadSuccess, photosLoadSuccess,
photosStartFetchingSpinner, 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) { function* photoUpload(f: File) {
try { try {
const hash = yield call(computeChecksumMd5, f); const hash = yield call(computeChecksumMd5, f);
@@ -218,6 +248,7 @@ export function* photosSaga() {
yield all([ yield all([
takeLatest(PhotoTypes.PHOTOS_LOAD_START, photosLoad), takeLatest(PhotoTypes.PHOTOS_LOAD_START, photosLoad),
takeLatest(PhotoTypes.PHOTOS_UPLOAD_START, photosUpload), takeLatest(PhotoTypes.PHOTOS_UPLOAD_START, photosUpload),
takeLatest(PhotoTypes.PHOTO_LOAD_START, photoLoad),
takeEvery(PhotoTypes.PHOTO_DELETE_START, photoDelete), takeEvery(PhotoTypes.PHOTO_DELETE_START, photoDelete),
]); ]);
} }