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 { 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<IHomeProps> {
path="/account"
component={Account}
/>
<Route
path="/photos/:id"
component={Photo}
/>
<Route path="/" component={Overview} />
</Switch>
</animated.div>

View File

@@ -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<IOverviewComponentProps> = (
props,
) => {
if (!props.photos && !props.fetching) {
if (!props.overviewLoaded && !props.overviewFetching) {
props.fetchPhotos();
}
if (!props.photos) {
return <LoadingStub spinner={props.fetchingSpinner} />;
return <LoadingStub spinner={props.overviewFetchingSpinner} />;
}
const photos = props.photos
@@ -46,9 +47,10 @@ export const OverviewComponent: React.FunctionComponent<IOverviewComponentProps>
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,
};
}

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 { 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<
<Card
className="photoCard"
interactive={true}
/*
onClick={() =>
this.props.history.push(`/docs/${this.props.doc.id}`)
this.props.history.push(`/photos/${this.props.photo.id}`)
}
*/
>
{fileExists ? (
<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 = {
photos: null,
fetching: false,
fetchingError: null,
fetchingSpinner: false,
overviewFetching: false,
overviewFetchingError: null,
overviewFetchingSpinner: false,
fetchPhotos: fetchPhotosFn,
};

View File

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

View File

@@ -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;

View File

@@ -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<number, IPhotoState>;
overviewFetching: boolean;
overviewLoaded: boolean;
overviewFetchingError: string | null;
overviewFetchingSpinner: boolean;
deleteCache: Record<number, IPhotoReqJSON>;
}
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<IPhotosState, PhotoAction> = (
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;

View File

@@ -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),
]);
}