mirror of
https://github.com/usatiuk/photos.git
synced 2025-10-28 15:27:49 +01:00
deleting and uploading photos
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
54
frontend/src/Photos/UploadButton.tsx
Normal file
54
frontend/src/Photos/UploadButton.tsx
Normal 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);
|
||||
@@ -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>;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user