deleting and uploading photos

This commit is contained in:
2020-10-15 11:18:32 +03:00
committed by Stepan Usatiuk
parent e1411b1c1f
commit 09a1dcf8ba
16 changed files with 648 additions and 33 deletions

View File

@@ -1,10 +1,23 @@
import { Position, Toaster } from "@blueprintjs/core";
import { IPhotoReqJSON } from "~../../src/entity/Photo";
export const AppToaster = Toaster.create({
className: "recipe-toaster",
position: Position.TOP,
});
export function showDeletionToast(cancelFn: () => void) {
AppToaster.show({
message: "Photo deleted!",
intent: "danger",
timeout: 2900,
action: {
text: "Undo",
onClick: cancelFn,
},
});
}
export function showPasswordSavedToast(): void {
AppToaster.show({
message: "Password saved!",
@@ -20,3 +33,30 @@ export function showPasswordNotSavedToast(error: string): void {
timeout: 2000,
});
}
export function showPhotoCreateFailToast(f: File, e: string): void {
AppToaster.show({
message: `Failed to create ${f.name}: ${e}`,
intent: "danger",
timeout: 1000,
});
}
export function showPhotoUploadJSONFailToast(
p: IPhotoReqJSON,
e: string,
): void {
AppToaster.show({
message: `Failed to upload ${p.hash}: ${e}`,
intent: "danger",
timeout: 1000,
});
}
export function showPhotoUploadFileFailToast(f: File, e: string): void {
AppToaster.show({
message: `Failed to upload ${f.name}: ${e}`,
intent: "danger",
timeout: 1000,
});
}

View File

@@ -7,6 +7,8 @@ import { photosLoadStart } from "~redux/photos/actions";
import { IPhotoReqJSON } from "~../../src/entity/Photo";
import { LoadingStub } from "~LoadingStub";
import { PhotoCard } from "./PhotoCard";
import { Button } from "@blueprintjs/core";
import { UploadButton } from "./UploadButton";
export interface IOverviewComponentProps {
photos: IPhotoReqJSON[] | null;
@@ -33,6 +35,9 @@ export const OverviewComponent: React.FunctionComponent<IOverviewComponentProps>
return (
<div id="overview">
<div id="actionbar">
<UploadButton />
</div>
<div className="list">{photos}</div>
</div>
);

View File

@@ -1,16 +1,79 @@
import { Card } from "@blueprintjs/core";
import { Card, ContextMenuTarget, Menu, MenuItem } from "@blueprintjs/core";
import * as React from "react";
import { IPhotoReqJSON } from "~../../src/entity/Photo";
import { getPhotoImgPath } from "~redux/api/photos";
import { showDeletionToast } from "~AppToaster";
import { Dispatch } from "redux";
import { photoDeleteCancel, photoDeleteStart } from "~redux/photos/actions";
import { connect } from "react-redux";
export interface IPhotoCardProps {
export interface IPhotoCardComponentProps {
photo: IPhotoReqJSON;
deletePhoto: (photo: IPhotoReqJSON) => void;
cancelDelete: (photo: IPhotoReqJSON) => void;
}
export const PhotoCard: React.FunctionComponent<IPhotoCardProps> = (props) => {
return (
<Card className="photoCard">
<img src={getPhotoImgPath(props.photo)}></img>
</Card>
);
};
@ContextMenuTarget
export class PhotoCardComponent extends React.PureComponent<
IPhotoCardComponentProps
> {
constructor(props: IPhotoCardComponentProps) {
super(props);
this.handleDelete = this.handleDelete.bind(this);
//this.handleEdit = this.handleEdit.bind(this);
}
public handleDelete(): void {
showDeletionToast(() => this.props.cancelDelete(this.props.photo));
this.props.deletePhoto(this.props.photo);
}
/*
public handleEdit() {
this.props.history.push(`/docs/${this.props.doc.id}/edit`);
}
*/
public render(): JSX.Element {
return (
<Card
className="photoCard"
interactive={true}
/*
onClick={() =>
this.props.history.push(`/docs/${this.props.doc.id}`)
}
*/
>
<img src={getPhotoImgPath(this.props.photo)}></img>
</Card>
);
}
public renderContextMenu(): JSX.Element {
return (
<Menu>
{/*
<MenuItem onClick={this.handleEdit} icon="edit" text="Edit" />
*/}
<MenuItem
onClick={this.handleDelete}
intent="danger"
icon="trash"
text="Delete"
/>
</Menu>
);
}
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
deletePhoto: (photo: IPhotoReqJSON) =>
dispatch(photoDeleteStart(photo)),
cancelDelete: (photo: IPhotoReqJSON) =>
dispatch(photoDeleteCancel(photo)),
};
}
export const PhotoCard = connect(null, mapDispatchToProps)(PhotoCardComponent);

View File

@@ -4,6 +4,13 @@
display: flex;
flex-direction: column;
#actionbar {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
}
.list {
display: flex;
flex-shrink: 0;

View File

@@ -0,0 +1,54 @@
import { Button } from "@blueprintjs/core";
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { photosUploadStart } from "~redux/photos/actions";
export interface IUploadButtonComponentProps {
startUpload: (files: FileList) => void;
}
export const UploadButtonComponent: React.FunctionComponent<IUploadButtonComponentProps> = (
props,
) => {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
props.startUpload(e.target.files);
}
};
return (
<>
<input
accept="image/*"
id="photosHiddenInput"
hidden
multiple
type="file"
ref={fileInputRef}
onChange={onInputChange}
/>
<Button
icon="upload"
text="Upload"
outlined={true}
onClick={() => {
fileInputRef.current?.click();
}}
/>
</>
);
};
function mapDispatchToProps(dispatch: Dispatch) {
return {
startUpload: (files: FileList) => dispatch(photosUploadStart(files)),
};
}
export const UploadButton = connect(
null,
mapDispatchToProps,
)(UploadButtonComponent);

View File

@@ -8,10 +8,10 @@ export async function login(
username: string,
password: string,
): Promise<IUserLoginRespBody> {
return (fetchJSON("/users/login", "POST", {
return fetchJSON("/users/login", "POST", {
username,
password,
}) as unknown) as Promise<IUserLoginRespBody>;
});
}
export async function signup(
@@ -19,9 +19,9 @@ export async function signup(
password: string,
email: string,
): Promise<IUserSignupRespBody> {
return (fetchJSON("/users/signup", "POST", {
return fetchJSON("/users/signup", "POST", {
username,
password,
email,
}) as unknown) as Promise<IUserSignupRespBody>;
});
}

View File

@@ -1,5 +1,10 @@
import { IPhotoReqJSON } from "~../../src/entity/Photo";
import { IPhotosListRespBody } from "~../../src/routes/photos";
import {
IPhotosByIDDeleteRespBody,
IPhotosListRespBody,
IPhotosNewRespBody,
IPhotosUploadRespBody,
} from "~../../src/routes/photos";
import { apiRoot } from "~env";
import { fetchJSONAuth } from "./utils";
@@ -8,7 +13,26 @@ export function getPhotoImgPath(photo: IPhotoReqJSON): string {
}
export async function fetchPhotosList(): Promise<IPhotosListRespBody> {
return (fetchJSONAuth("/photos/list", "GET") as unknown) as Promise<
IPhotosListRespBody
>;
return fetchJSONAuth("/photos/list", "GET");
}
export async function createPhoto(
hash: string,
size: string,
format: string,
): Promise<IPhotosNewRespBody> {
return fetchJSONAuth("/photos/new", "POST", { hash, size, format });
}
export async function uploadPhoto(
file: File,
id: number,
): Promise<IPhotosUploadRespBody> {
return fetchJSONAuth(`/photos/upload/${id}`, "POST", file);
}
export async function deletePhoto(
photo: IPhotoReqJSON,
): Promise<IPhotosByIDDeleteRespBody> {
return fetchJSONAuth(`/photos/byID/${photo.id}`, "DELETE");
}

View File

@@ -10,7 +10,7 @@ export async function fetchUser(): Promise<IUserGetRespBody> {
export async function changeUserPassword(
newPassword: string,
): Promise<IUserEditRespBody> {
return (fetchJSONAuth("/users/edit", "POST", {
return fetchJSONAuth("/users/edit", "POST", {
password: newPassword,
}) as unknown) as Promise<IUserEditRespBody>;
});
}

View File

@@ -17,19 +17,33 @@ export function deleteToken(): void {
export async function fetchJSON(
path: string,
method: string,
body?: string | Record<string, unknown>,
body?: string | Record<string, unknown> | File,
headers?: Record<string, string>,
): Promise<Record<string, unknown>> {
if (typeof body === "object") {
if (typeof body === "object" && !(body instanceof File)) {
body = JSON.stringify(body);
headers = {
...headers,
"Content-Type": "application/json",
};
}
if (body instanceof File) {
const formData = new FormData();
formData.append("photo", body);
const response = await fetch(apiRoot + path, {
method,
headers,
body: formData,
});
const json = (await response.json()) as Record<string, unknown>;
return json;
}
const response = await fetch(apiRoot + path, {
method,
body,
headers: {
...headers,
"Content-Type": "application/json",
},
headers,
});
const json = (await response.json()) as Record<string, unknown>;
return json;
@@ -38,7 +52,7 @@ export async function fetchJSON(
export async function fetchJSONAuth(
path: string,
method: string,
body?: string | Record<string, unknown>,
body?: string | Record<string, unknown> | File,
headers?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
if (token) {

View File

@@ -1,11 +1,25 @@
import { Action } from "redux";
import { IPhotoReqJSON, Photo } from "~../../src/entity/Photo";
import {
showPhotoCreateFailToast,
showPhotoUploadFileFailToast,
showPhotoUploadJSONFailToast,
} from "~AppToaster";
export enum PhotoTypes {
PHOTOS_LOAD_START = "PHOTOS_LOAD",
PHOTOS_LOAD_SUCCESS = "PHOTOS_LOAD_SUCCESS",
PHOTOS_LOAD_FAIL = "PHOTOS_LOAD_FAIL",
PHOTOS_UPLOAD_START = "PHOTOS_UPLOAD",
PHOTO_CREATE_SUCCESS = "PHOTO_CREATE_SUCCESS",
PHOTO_CREATE_FAIL = "PHOTO_CREATE_FAIL",
PHOTO_UPLOAD_SUCCESS = "PHOTO_UPLOAD_SUCCESS",
PHOTO_UPLOAD_FAIL = "PHOTO_UPLOAD_FAIL",
PHOTOS_START_FETCHING_SPINNER = "PHOTOS_START_FETCHING_SPINNER",
PHOTO_DELETE_START = "PHOTO_DELETE_START",
PHOTO_DELETE_SUCCESS = "PHOTO_DELETE_SUCCESS",
PHOTO_DELETE_FAIL = "PHOTO_DELETE_FAIL",
PHOTO_DELETE_CANCEL = "PHOTO_DELETE_CANCEL",
}
export interface IPhotosLoadStartAction extends Action {
@@ -22,6 +36,54 @@ export interface IPhotosLoadFailAction extends Action {
error: string;
}
export interface IPhotosUploadStartAction extends Action {
type: PhotoTypes.PHOTOS_UPLOAD_START;
files: FileList;
}
export interface IPhotoUploadSuccessAction extends Action {
type: PhotoTypes.PHOTO_UPLOAD_SUCCESS;
photo: IPhotoReqJSON;
}
export interface IPhotoUploadFailAction extends Action {
type: PhotoTypes.PHOTO_UPLOAD_FAIL;
photo: IPhotoReqJSON;
error: string;
}
export interface IPhotoCreateSuccessAction extends Action {
type: PhotoTypes.PHOTO_CREATE_SUCCESS;
photo: IPhotoReqJSON;
}
export interface IPhotoCreateFailAction extends Action {
type: PhotoTypes.PHOTO_CREATE_FAIL;
file: File;
error: string;
}
export interface IPhotoDeleteStartAction extends Action {
type: PhotoTypes.PHOTO_DELETE_START;
photo: IPhotoReqJSON;
}
export interface IPhotoDeleteSuccessAction extends Action {
type: PhotoTypes.PHOTO_DELETE_SUCCESS;
photo: IPhotoReqJSON;
}
export interface IPhotoDeleteFailAction extends Action {
type: PhotoTypes.PHOTO_DELETE_FAIL;
photo: IPhotoReqJSON;
error?: string;
}
export interface IPhotoDeleteCancelAction extends Action {
type: PhotoTypes.PHOTO_DELETE_CANCEL;
photo: IPhotoReqJSON;
}
export interface IPhotosStartFetchingSpinner extends Action {
type: PhotoTypes.PHOTOS_START_FETCHING_SPINNER;
}
@@ -30,6 +92,47 @@ export function photosLoadStart(): IPhotosLoadStartAction {
return { type: PhotoTypes.PHOTOS_LOAD_START };
}
export function photosUploadStart(files: FileList): IPhotosUploadStartAction {
return { type: PhotoTypes.PHOTOS_UPLOAD_START, files };
}
export function photoUploadSuccess(
photo: IPhotoReqJSON,
): IPhotoUploadSuccessAction {
return { type: PhotoTypes.PHOTO_UPLOAD_SUCCESS, photo };
}
export function photoUploadFail(
photo: IPhotoReqJSON,
error: string,
): IPhotoUploadFailAction {
showPhotoUploadJSONFailToast(photo, error);
return { type: PhotoTypes.PHOTO_UPLOAD_FAIL, photo, error };
}
export function photoUploadFailWithFile(
photo: IPhotoReqJSON,
file: File,
error: string,
): IPhotoUploadFailAction {
showPhotoUploadFileFailToast(file, error);
return { type: PhotoTypes.PHOTO_UPLOAD_FAIL, photo, error };
}
export function photoCreateSuccess(
photo: IPhotoReqJSON,
): IPhotoCreateSuccessAction {
return { type: PhotoTypes.PHOTO_CREATE_SUCCESS, photo };
}
export function photoCreateFail(
file: File,
error: string,
): IPhotoCreateFailAction {
showPhotoCreateFailToast(file, error);
return { type: PhotoTypes.PHOTO_CREATE_FAIL, file, error };
}
export function photosLoadSuccess(
photos: IPhotoReqJSON[],
): IPhotosLoadSuccessAction {
@@ -40,6 +143,29 @@ export function photosLoadFail(error: string): IPhotosLoadFailAction {
return { type: PhotoTypes.PHOTOS_LOAD_FAIL, error };
}
export function photoDeleteStart(
photo: IPhotoReqJSON,
): IPhotoDeleteStartAction {
return { type: PhotoTypes.PHOTO_DELETE_START, photo };
}
export function photoDeleteSuccess(
photo: IPhotoReqJSON,
): IPhotoDeleteSuccessAction {
return { type: PhotoTypes.PHOTO_DELETE_SUCCESS, photo };
}
export function photoDeleteFail(
photo: IPhotoReqJSON,
error?: string,
): IPhotoDeleteFailAction {
return { type: PhotoTypes.PHOTO_DELETE_FAIL, photo, error };
}
export function photoDeleteCancel(
photo: IPhotoReqJSON,
): IPhotoDeleteCancelAction {
return { type: PhotoTypes.PHOTO_DELETE_CANCEL, photo };
}
export function photosStartFetchingSpinner(): IPhotosStartFetchingSpinner {
return { type: PhotoTypes.PHOTOS_START_FETCHING_SPINNER };
}
@@ -48,4 +174,13 @@ export type PhotoAction =
| IPhotosLoadStartAction
| IPhotosLoadFailAction
| IPhotosLoadSuccessAction
| IPhotosStartFetchingSpinner;
| IPhotosStartFetchingSpinner
| IPhotosUploadStartAction
| IPhotoCreateFailAction
| IPhotoCreateSuccessAction
| IPhotoUploadFailAction
| IPhotoUploadSuccessAction
| IPhotoDeleteFailAction
| IPhotoDeleteStartAction
| IPhotoDeleteSuccessAction
| IPhotoDeleteCancelAction;

View File

@@ -8,6 +8,8 @@ export interface IPhotosState {
fetching: boolean;
fetchingError: string | null;
fetchingSpinner: boolean;
deleteCache: Record<number, IPhotoReqJSON>;
}
const defaultPhotosState: IPhotosState = {
@@ -15,6 +17,8 @@ const defaultPhotosState: IPhotosState = {
fetching: false,
fetchingError: null,
fetchingSpinner: false,
deleteCache: {},
};
export const photosReducer: Reducer<IPhotosState, PhotoAction> = (
@@ -36,6 +40,77 @@ export const photosReducer: Reducer<IPhotosState, PhotoAction> = (
return { ...defaultPhotosState, photos: action.photos };
case PhotoTypes.PHOTOS_LOAD_FAIL:
return { ...defaultPhotosState, fetchingError: action.error };
case PhotoTypes.PHOTO_CREATE_SUCCESS:
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 };
} else {
return state;
}
case PhotoTypes.PHOTO_CREATE_FAIL:
// TODO: Handle photo create fail
return state;
case PhotoTypes.PHOTO_UPLOAD_SUCCESS:
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 };
} else {
return state;
}
case PhotoTypes.PHOTO_DELETE_START:
if (state.photos) {
const photos = state.photos;
const delPhoto = photos.find((p) => p.id === action.photo.id);
if (delPhoto) {
const photosCleaned = photos.filter(
(p) => p.id !== action.photo.id,
);
const delCache = { ...state.deleteCache };
delCache[delPhoto?.id] = delPhoto;
return {
...state,
photos: photosCleaned,
deleteCache: delCache,
};
} else {
return state;
}
} else {
return state;
}
case PhotoTypes.PHOTO_DELETE_SUCCESS: {
const delCache = { ...state.deleteCache };
if (delCache[action.photo.id]) {
delete delCache[action.photo.id];
}
return { ...state, deleteCache: delCache };
break;
}
case PhotoTypes.PHOTO_DELETE_FAIL:
case PhotoTypes.PHOTO_DELETE_CANCEL: {
const delCache = { ...state.deleteCache };
let photos: IPhotoReqJSON[] = [];
if (state.photos) {
photos = [...state.photos];
}
if (delCache[action.photo.id]) {
photos = [...photos, delCache[action.photo.id]];
delete delCache[action.photo.id];
}
return { ...state, deleteCache: delCache, photos };
break;
}
case PhotoTypes.PHOTO_UPLOAD_FAIL:
// TODO: Handle photo upload fail
return state;
default:
return state;
}

View File

@@ -7,14 +7,97 @@ import {
put,
race,
takeLatest,
takeEvery,
take,
} from "redux-saga/effects";
import { fetchPhotosList } from "~redux/api/photos";
import * as SparkMD5 from "spark-md5";
import {
createPhoto,
deletePhoto,
fetchPhotosList,
uploadPhoto,
} from "~redux/api/photos";
import {
IPhotoDeleteStartAction,
IPhotosUploadStartAction,
photoCreateFail,
photoCreateSuccess,
photoDeleteFail,
photoDeleteSuccess,
photosLoadFail,
photosLoadSuccess,
photosStartFetchingSpinner,
PhotoTypes,
photoUploadFail,
photoUploadFailWithFile,
photoUploadSuccess,
} from "./actions";
import { IPhotosNewRespBody } from "~../../src/routes/photos";
// Thanks, https://dev.to/qortex/compute-md5-checksum-for-a-file-in-typescript-59a4
function computeChecksumMd5(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const chunkSize = 2097152; // Read in chunks of 2MB
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
let cursor = 0; // current cursor in file
fileReader.onerror = function (): void {
reject("MD5 computation failed - error reading the file");
};
// read chunk starting at `cursor` into memory
function processChunk(chunk_start: number): void {
const chunk_end = Math.min(file.size, chunk_start + chunkSize);
fileReader.readAsArrayBuffer(file.slice(chunk_start, chunk_end));
}
// when it's available in memory, process it
// If using TS >= 3.6, you can use `FileReaderProgressEvent` type instead
// of `any` for `e` variable, otherwise stick with `any`
// See https://github.com/Microsoft/TypeScript/issues/25510
fileReader.onload = function (e: ProgressEvent<FileReader>): void {
if (e.target?.result) {
spark.append(e.target.result as ArrayBuffer); // Accumulate chunk to md5 computation
}
cursor += chunkSize; // Move past this chunk
if (cursor < file.size) {
// Enqueue next chunk to be accumulated
processChunk(cursor);
} else {
// Computation ended, last chunk has been processed. Return as Promise value.
// This returns the base64 encoded md5 hash, which is what
// Rails ActiveStorage or cloud services expect
// resolve(btoa(spark.end(true)));
// If you prefer the hexdigest form (looking like
// '7cf530335b8547945f1a48880bc421b2'), replace the above line with:
resolve(spark.end());
}
};
processChunk(0);
});
}
function computeSize(f: File) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = window.URL.createObjectURL(f);
img.onload = (e) => {
const { naturalWidth, naturalHeight } = img;
if (!naturalWidth || !naturalHeight) {
reject("Error loading image");
}
resolve(`${naturalWidth}x${naturalHeight}`);
};
img.onerror = (e) => {
reject("Error loading image");
};
});
}
function* startSpinner() {
yield delay(300);
@@ -47,6 +130,94 @@ function* photosLoad() {
}
}
export function* photosSaga() {
yield all([takeLatest(PhotoTypes.PHOTOS_LOAD_START, photosLoad)]);
function* photoUpload(f: File) {
try {
const hash = yield call(computeChecksumMd5, f);
const size = yield call(computeSize, f);
const format = f.type;
const { response, timeout } = yield race({
response: call(createPhoto, hash, size, format),
timeout: delay(10000),
});
if (timeout) {
yield put(photoCreateFail(f, "Timeout"));
return;
}
if (response.data || response.error === "Photo already exists") {
const photo = (response as IPhotosNewRespBody).data;
yield put(photoCreateSuccess(photo));
try {
const { response, timeout } = yield race({
response: call(uploadPhoto, f, photo.id),
timeout: delay(10000),
});
if (timeout) {
yield put(photoUploadFailWithFile(photo, f, "Timeout"));
return;
}
if (response.data) {
const photo = response.data;
yield put(photoUploadSuccess(photo));
} else {
yield put(
photoUploadFailWithFile(photo, f, response.error),
);
}
} catch (e) {
yield put(photoUploadFailWithFile(photo, f, "Internal error"));
}
} else {
yield put(photoCreateFail(f, response.error));
}
} catch (e) {
yield put(photoCreateFail(f, "Internal error"));
}
}
function* photosUpload(action: IPhotosUploadStartAction) {
const files = Array.from(action.files);
yield all(files.map((f) => call(photoUpload, f)));
}
function* photoDelete(action: IPhotoDeleteStartAction) {
try {
const { cancelled } = yield race({
timeout: delay(3000),
cancelled: take(PhotoTypes.PHOTO_DELETE_CANCEL),
});
if (!cancelled) {
const { response, timeout } = yield race({
response: call(deletePhoto, action.photo),
timeout: delay(10000),
});
if (timeout) {
yield put(photoDeleteFail(action.photo, "Timeout"));
return;
}
if (response) {
if (response.data == null) {
yield put(photoDeleteFail(action.photo, response.error));
} else {
yield put(photoDeleteSuccess(action.photo));
}
}
}
} catch (e) {
yield put(photoDeleteFail(action.photo, "Internal error"));
}
}
export function* photosSaga() {
yield all([
takeLatest(PhotoTypes.PHOTOS_LOAD_START, photosLoad),
takeLatest(PhotoTypes.PHOTOS_UPLOAD_START, photosUpload),
takeEvery(PhotoTypes.PHOTO_DELETE_START, photoDelete),
]);
}