select and delete multiple photos

This commit is contained in:
2022-05-02 22:03:06 +02:00
committed by Stepan Usatiuk
parent 3800f19768
commit ff058b7487
10 changed files with 277 additions and 94 deletions

View File

@@ -4,7 +4,36 @@
--photoOverlayDrawerWidth: 5rem;
}
#photoOverlayContainer {
.operationsOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: $pt-navbar-height;
transition: all 0.3s;
&.bp4-ovarlay-open {
z-index: 99;
}
&.bp4-overlay-enter {
opacity: 0;
}
&.bp4-overlay-enter-active {
opacity: 1;
}
&.bp4-overlay-exit {
opacity: 1;
}
&.bp4-overlay-exit-active {
opacity: 0;
}
}
.operationsOverlay #photoOverlayContainer {
width: 100%;
height: 100%;
align-items: center;
@@ -40,11 +69,9 @@
opacity: 1;
}
}
}
}
#photoOverlayDrawer {
position: absolute;
top: 0;
@@ -89,7 +116,6 @@
}
}
.bp4-overlay-exit {
opacity: 1;
@@ -149,14 +175,11 @@
.month,
.year {
h3,
h2 {
margin-top: 1rem;
margin-left: 0.25rem;
}
}
.list {
@@ -203,9 +226,9 @@
}
}
}
}
.bp4-dark {
#overview {}
#overview {
}
}

View File

@@ -3,22 +3,29 @@ import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { IAppState } from "../redux/reducers";
import { photosLoadStart } from "../redux/photos/actions";
import {
photosDeleteCancel,
photosDeleteStart,
photosLoadStart,
} from "../redux/photos/actions";
import { IPhotoReqJSON } from "../../../src/entity/Photo";
import { LoadingStub } from "../LoadingStub";
import { PhotoCard } from "./PhotoCard";
import {
Alignment,
Button,
Classes,
H1,
H2,
H3,
Navbar,
Overlay,
Spinner,
} from "@blueprintjs/core";
import { UploadButton } from "./UploadButton";
import { Photo } from "./Photo";
import { getPhotoThumbPath } from "../redux/api/photos";
import { showDeletionToast } from "../AppToaster";
export interface IOverviewComponentProps {
photos: IPhotoReqJSON[];
@@ -27,8 +34,11 @@ export interface IOverviewComponentProps {
overviewFetching: boolean;
overviewFetchingError: string | null;
overviewFetchingSpinner: boolean;
darkMode: boolean;
fetchPhotos: () => void;
startDeletePhotos: (photos: IPhotoReqJSON[]) => void;
cancelDelete: (photos: IPhotoReqJSON[]) => void;
}
const PhotoCardM = React.memo(PhotoCard);
@@ -183,6 +193,49 @@ export const OverviewComponent: React.FunctionComponent<
</div>
</div>
</Overlay>
<Overlay
lazy
usePortal
isOpen={selectedPhotos.size > 0}
transitionDuration={300}
hasBackdrop={false}
>
<div className={"operationsOverlay"}>
<Navbar
className={props.darkMode ? Classes.DARK : undefined}
>
<Navbar.Group align={Alignment.LEFT}>
<Button minimal={true} icon="edit">
Select
</Button>
<Navbar.Divider />
</Navbar.Group>
<Navbar.Group align={Alignment.RIGHT}>
<Button
className="bp4-minimal"
icon="trash"
text="Delete"
onClick={() => {
const photosObjectsWithIds =
props.photos.filter((p) =>
selectedPhotosRef.current.has(p.id),
);
showDeletionToast(() =>
props.cancelDelete(
photosObjectsWithIds,
),
);
props.startDeletePhotos(
photosObjectsWithIds,
);
selectedPhotosRef.current.clear();
}}
/>
</Navbar.Group>
</Navbar>
</div>
</Overlay>
<div id="overviewContainer" onScroll={onLoaderScroll}>
<div id="overview">
<div id="actionbar">
@@ -206,11 +259,18 @@ function mapStateToProps(state: IAppState) {
overviewFetchingError: state.photos.overviewFetchingError,
overviewFetchingSpinner: state.photos.overviewFetchingSpinner,
triedLoading: state.photos.triedLoading,
darkMode: state.localSettings.darkMode,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return { fetchPhotos: () => dispatch(photosLoadStart()) };
return {
fetchPhotos: () => dispatch(photosLoadStart()),
startDeletePhotos: (photos: IPhotoReqJSON[]) =>
dispatch(photosDeleteStart(photos)),
cancelDelete: (photos: IPhotoReqJSON[]) =>
dispatch(photosDeleteCancel(photos)),
};
}
export const Overview = connect(

View File

@@ -12,7 +12,7 @@ import { IPhotoReqJSON } from "../../../src/entity/Photo";
import { getPhotoImgPath, getPhotoThumbPath } from "../redux/api/photos";
import { showDeletionToast } from "../AppToaster";
import { Dispatch } from "redux";
import { photoDeleteCancel, photoDeleteStart } from "../redux/photos/actions";
import { photosDeleteCancel, photosDeleteStart } from "../redux/photos/actions";
import { connect } from "react-redux";
import { LoadingStub } from "../LoadingStub";
import { RouteComponentProps, withRouter } from "react-router";
@@ -23,8 +23,8 @@ export interface IPhotoCardComponentProps extends RouteComponentProps {
selected: boolean;
id: string;
deletePhoto: (photo: IPhotoReqJSON) => void;
cancelDelete: (photo: IPhotoReqJSON) => void;
deletePhoto: (photos: IPhotoReqJSON[]) => void;
cancelDelete: (photos: IPhotoReqJSON[]) => void;
onClick: (e: React.MouseEvent<HTMLElement>) => void;
}
@@ -55,8 +55,8 @@ export class PhotoCardComponent extends React.PureComponent<
}
public handleDelete(): void {
showDeletionToast(() => this.props.cancelDelete(this.props.photo));
this.props.deletePhoto(this.props.photo);
showDeletionToast(() => this.props.cancelDelete([this.props.photo]));
this.props.deletePhoto([this.props.photo]);
}
/*
public handleEdit() {
@@ -122,10 +122,10 @@ export class PhotoCardComponent extends React.PureComponent<
function mapDispatchToProps(dispatch: Dispatch) {
return {
deletePhoto: (photo: IPhotoReqJSON) =>
dispatch(photoDeleteStart(photo)),
cancelDelete: (photo: IPhotoReqJSON) =>
dispatch(photoDeleteCancel(photo)),
deletePhoto: (photos: IPhotoReqJSON[]) =>
dispatch(photosDeleteStart(photos)),
cancelDelete: (photos: IPhotoReqJSON[]) =>
dispatch(photosDeleteCancel(photos)),
};
}

View File

@@ -1,7 +1,7 @@
import { IPhotoReqJSON } from "../../../../src/entity/Photo";
import {
IPhotosByIDDeleteRespBody,
IPhotosByIDGetRespBody,
IPhotosDeleteRespBody,
IPhotosListRespBody,
IPhotosNewRespBody,
IPhotosUploadRespBody,
@@ -49,8 +49,8 @@ export async function uploadPhoto(
return fetchJSONAuth(`/photos/upload/${id}`, "POST", file);
}
export async function deletePhoto(
photo: IPhotoReqJSON,
): Promise<IPhotosByIDDeleteRespBody> {
return fetchJSONAuth(`/photos/byID/${photo.id}`, "DELETE");
export async function deletePhotos(
photos: IPhotoReqJSON[],
): Promise<IPhotosDeleteRespBody> {
return fetchJSONAuth(`/photos/delete`, "POST", { photos });
}

View File

@@ -23,10 +23,10 @@ export enum PhotoTypes {
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",
PHOTOS_DELETE_START = "PHOTOS_DELETE_START",
PHOTOS_DELETE_SUCCESS = "PHOTOS_DELETE_SUCCESS",
PHOTOS_DELETE_FAIL = "PHOTOS_DELETE_FAIL",
PHOTOS_DELETE_CANCEL = "PHOTOS_DELETE_CANCEL",
}
export interface IPhotosLoadStartAction extends Action {
@@ -109,25 +109,25 @@ export interface IPhotoCreateFailAction extends Action {
error: string;
}
export interface IPhotoDeleteStartAction extends Action {
type: PhotoTypes.PHOTO_DELETE_START;
photo: IPhotoReqJSON;
export interface IPhotosDeleteStartAction extends Action {
type: PhotoTypes.PHOTOS_DELETE_START;
photos: IPhotoReqJSON[];
}
export interface IPhotoDeleteSuccessAction extends Action {
type: PhotoTypes.PHOTO_DELETE_SUCCESS;
photo: IPhotoReqJSON;
export interface IPhotosDeleteSuccessAction extends Action {
type: PhotoTypes.PHOTOS_DELETE_SUCCESS;
photos: IPhotoReqJSON[];
}
export interface IPhotoDeleteFailAction extends Action {
type: PhotoTypes.PHOTO_DELETE_FAIL;
photo: IPhotoReqJSON;
export interface IPhotosDeleteFailAction extends Action {
type: PhotoTypes.PHOTOS_DELETE_FAIL;
photos: IPhotoReqJSON[];
error?: string;
}
export interface IPhotoDeleteCancelAction extends Action {
type: PhotoTypes.PHOTO_DELETE_CANCEL;
photo: IPhotoReqJSON;
export interface IPhotosDeleteCancelAction extends Action {
type: PhotoTypes.PHOTOS_DELETE_CANCEL;
photos: IPhotoReqJSON[];
}
export interface IPhotosStartFetchingSpinner extends Action {
@@ -220,27 +220,27 @@ export function photoLoadFail(id: number, error: string): IPhotoLoadFailAction {
return { type: PhotoTypes.PHOTO_LOAD_FAIL, id, error };
}
export function photoDeleteStart(
photo: IPhotoReqJSON,
): IPhotoDeleteStartAction {
return { type: PhotoTypes.PHOTO_DELETE_START, photo };
export function photosDeleteStart(
photos: IPhotoReqJSON[],
): IPhotosDeleteStartAction {
return { type: PhotoTypes.PHOTOS_DELETE_START, photos };
}
export function photoDeleteSuccess(
photo: IPhotoReqJSON,
): IPhotoDeleteSuccessAction {
return { type: PhotoTypes.PHOTO_DELETE_SUCCESS, photo };
export function photosDeleteSuccess(
photos: IPhotoReqJSON[],
): IPhotosDeleteSuccessAction {
return { type: PhotoTypes.PHOTOS_DELETE_SUCCESS, photos };
}
export function photoDeleteFail(
photo: IPhotoReqJSON,
export function photosDeleteFail(
photos: IPhotoReqJSON[],
error?: string,
): IPhotoDeleteFailAction {
return { type: PhotoTypes.PHOTO_DELETE_FAIL, photo, error };
): IPhotosDeleteFailAction {
return { type: PhotoTypes.PHOTOS_DELETE_FAIL, photos, error };
}
export function photoDeleteCancel(
photo: IPhotoReqJSON,
): IPhotoDeleteCancelAction {
return { type: PhotoTypes.PHOTO_DELETE_CANCEL, photo };
export function photosDeleteCancel(
photos: IPhotoReqJSON[],
): IPhotosDeleteCancelAction {
return { type: PhotoTypes.PHOTOS_DELETE_CANCEL, photos };
}
export function photosStartFetchingSpinner(): IPhotosStartFetchingSpinner {
@@ -257,10 +257,10 @@ export type PhotoAction =
| IPhotoCreateSuccessAction
| IPhotoUploadFailAction
| IPhotoUploadSuccessAction
| IPhotoDeleteFailAction
| IPhotoDeleteStartAction
| IPhotoDeleteSuccessAction
| IPhotoDeleteCancelAction
| IPhotosDeleteFailAction
| IPhotosDeleteStartAction
| IPhotosDeleteSuccessAction
| IPhotosDeleteCancelAction
| IPhotoLoadFailAction
| IPhotoLoadStartAction
| IPhotoLoadSuccessAction

View File

@@ -196,15 +196,18 @@ export const photosReducer: Reducer<IPhotosState, PhotoAction> = (
photosUploading: state.photosUploading - 1,
};
}
case PhotoTypes.PHOTO_DELETE_START: {
case PhotoTypes.PHOTOS_DELETE_START: {
const photos = state.photos;
const delPhoto = photos.find((p) => p.id === action.photo.id);
if (delPhoto) {
const photoIds = action.photos.map((p) => p.id);
const delPhotos = photos.find((p) => photoIds.includes(p.id));
if (delPhotos) {
const photosCleaned = photos.filter(
(p) => p.id !== action.photo.id,
(p) => !photoIds.includes(p.id),
);
const delCache = { ...state.deleteCache };
delCache[delPhoto?.id] = delPhoto;
for (const photo of action.photos) {
delCache[photo.id] = photo;
}
return {
...state,
photos: sortPhotos(photosCleaned),
@@ -214,21 +217,25 @@ export const photosReducer: Reducer<IPhotosState, PhotoAction> = (
return state;
}
}
case PhotoTypes.PHOTO_DELETE_SUCCESS: {
case PhotoTypes.PHOTOS_DELETE_SUCCESS: {
const delCache = { ...state.deleteCache };
if (delCache[action.photo.id]) {
delete delCache[action.photo.id];
for (const photo of action.photos) {
if (delCache[photo.id]) {
delete delCache[photo.id];
}
}
return { ...state, deleteCache: delCache };
break;
}
case PhotoTypes.PHOTO_DELETE_FAIL:
case PhotoTypes.PHOTO_DELETE_CANCEL: {
case PhotoTypes.PHOTOS_DELETE_FAIL:
case PhotoTypes.PHOTOS_DELETE_CANCEL: {
const delCache = { ...state.deleteCache };
let photos: IPhotoReqJSON[] = [...state.photos];
if (delCache[action.photo.id]) {
photos = sortPhotos([...photos, delCache[action.photo.id]]);
delete delCache[action.photo.id];
for (const photo of action.photos) {
if (delCache[photo.id]) {
photos = sortPhotos([...photos, delCache[photo.id]]);
delete delCache[photo.id];
}
}
return { ...state, deleteCache: delCache, photos };
break;

View File

@@ -14,21 +14,21 @@ import {
import * as SparkMD5 from "spark-md5";
import {
createPhoto,
deletePhoto,
deletePhotos,
fetchPhoto,
fetchPhotosList,
uploadPhoto,
} from "../../redux/api/photos";
import {
IPhotoDeleteStartAction,
IPhotosDeleteStartAction,
IPhotoLoadStartAction,
IPhotosUploadStartAction,
photoCreateFail,
photoCreateQueue,
photoCreateStart,
photoCreateSuccess,
photoDeleteFail,
photoDeleteSuccess,
photosDeleteFail,
photosDeleteSuccess,
photoLoadFail,
photoLoadSuccess,
photosLoadFail,
@@ -250,34 +250,35 @@ function* photosUpload(action: IPhotosUploadStartAction) {
}
}
function* photoDelete(action: IPhotoDeleteStartAction) {
function* photosDelete(action: IPhotosDeleteStartAction) {
try {
const { cancelled } = yield race({
timeout: delay(3000),
cancelled: take(PhotoTypes.PHOTO_DELETE_CANCEL),
//FIXME: what happens if we delete multiple photos and then cancel some of them?
cancelled: take(PhotoTypes.PHOTOS_DELETE_CANCEL),
});
if (!cancelled) {
const { response, timeout } = yield race({
response: call(deletePhoto, action.photo),
response: call(deletePhotos, action.photos),
timeout: delay(10000),
});
if (timeout) {
yield put(photoDeleteFail(action.photo, "Timeout"));
yield put(photosDeleteFail(action.photos, "Timeout"));
return;
}
if (response) {
if (response.data == null) {
yield put(photoDeleteFail(action.photo, response.error));
yield put(photosDeleteFail(action.photos, response.error));
} else {
yield put(photoDeleteSuccess(action.photo));
yield put(photosDeleteSuccess(action.photos));
}
}
}
} catch (e) {
yield put(photoDeleteFail(action.photo, "Internal error"));
yield put(photosDeleteFail(action.photos, "Internal error"));
}
}
@@ -286,7 +287,7 @@ export function* photosSaga() {
takeLatest(PhotoTypes.PHOTOS_LOAD_START, photosLoad),
takeLatest(PhotoTypes.PHOTOS_UPLOAD_START, photosUpload),
takeLatest(PhotoTypes.PHOTO_LOAD_START, photoLoad),
takeEvery(PhotoTypes.PHOTO_DELETE_START, photoDelete),
takeEvery(PhotoTypes.PHOTOS_DELETE_START, photosDelete),
takeEvery(PhotoTypes.PHOTO_CREATE_QUEUE, photoCreate),
takeEvery(PhotoTypes.PHOTO_CREATE_SUCCESS, photoCreate),
takeEvery(PhotoTypes.PHOTO_CREATE_FAIL, photoCreate),

View File

@@ -9,7 +9,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"noImplicitAny": true,
"noImplicitAny": false,
"allowSyntheticDefaultImports": true,
"strictFunctionTypes": true,
"strictNullChecks": true,

View File

@@ -8,6 +8,7 @@ import { getHash, getSize } from "~util";
import * as jwt from "jsonwebtoken";
import { config } from "~config";
import { ValidationError } from "class-validator";
import { In } from "typeorm";
export const photosRouter = new Router();
@@ -277,7 +278,10 @@ photosRouter.get("/photos/showByID/:id/:token", async (ctx) => {
return;
}
if (ctx.request.query["size"] && typeof ctx.request.query["size"] == "string") {
if (
ctx.request.query["size"] &&
typeof ctx.request.query["size"] == "string"
) {
const size = parseInt(ctx.request.query["size"]);
await send(ctx, await photo.getReadyThumbnailPath(size));
return;
@@ -309,7 +313,10 @@ photosRouter.get("/photos/showByID/:id", async (ctx) => {
return;
}
if (ctx.request.query["size"] && typeof ctx.request.query["size"] == "string") {
if (
ctx.request.query["size"] &&
typeof ctx.request.query["size"] == "string"
) {
const size = parseInt(ctx.request.query["size"]);
await send(ctx, await photo.getReadyThumbnailPath(size));
return;
@@ -347,7 +354,7 @@ photosRouter.get("/photos/getShowByIDToken/:id", async (ctx) => {
ctx.body = { error: false, data: token } as IPhotosGetShowTokenByID;
});
export type IPhotosByIDDeleteRespBody = IAPIResponse<boolean>;
export type IPhotoByIDDeleteRespBody = IAPIResponse<boolean>;
photosRouter.delete("/photos/byID/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
@@ -376,5 +383,42 @@ photosRouter.delete("/photos/byID/:id", async (ctx) => {
ctx.body = {
error: false,
data: true,
} as IPhotosByIDDeleteRespBody;
} as IPhotoByIDDeleteRespBody;
});
export interface IPhotosDeleteBody {
photos: IPhotoReqJSON[];
}
export type IPhotosDeleteRespBody = IAPIResponse<boolean>;
photosRouter.post("/photos/delete", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
}
const body = ctx.request.body as IPhotosDeleteBody;
const { photos } = body;
if (!photos || !Array.isArray(photos) || photos.length == 0) {
ctx.throw(400);
return;
}
const { user } = ctx.state;
try {
await Photo.delete({
id: In(photos.map((photo) => photo.id)),
user,
});
ctx.body = {
error: false,
data: true,
} as IPhotosDeleteRespBody;
} catch (e) {
ctx.body = {
data: null,
error: "Internal server error",
} as IPhotosDeleteRespBody;
}
});

View File

@@ -4,7 +4,11 @@ import * as request from "supertest";
import { getConnection } from "typeorm";
import { app } from "~app";
import { Photo, IPhotoReqJSON } from "~entity/Photo";
import { IPhotosListRespBody, IPhotosNewPostBody } from "~routes/photos";
import {
IPhotosDeleteBody,
IPhotosListRespBody,
IPhotosNewPostBody,
} from "~routes/photos";
import * as fs from "fs/promises";
import { constants as fsConstants } from "fs";
import * as jwt from "jsonwebtoken";
@@ -618,10 +622,14 @@ describe("photos", function () {
512,
);
const response = await request(callback)
.delete(`/photos/byID/${seed.dogPhoto.id}`)
.post(`/photos/delete`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
"Content-Type": "application/json",
})
.send({
photos: [await seed.dogPhoto.toReqJSON()],
} as IPhotosDeleteBody)
.expect(200);
expect(response.body.error).to.be.false;
@@ -636,4 +644,44 @@ describe("photos", function () {
assert(true);
}
});
it("should delete two photos", async function () {
const photo1Path = seed.dogPhoto.getPath();
const photo2Path = seed.catPhoto.getPath();
const photo1SmallThumbPath = await seed.dogPhoto.getReadyThumbnailPath(
512,
);
const photo2SmallThumbPath = await seed.catPhoto.getReadyThumbnailPath(
512,
);
const response = await request(callback)
.post(`/photos/delete`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
"Content-Type": "application/json",
})
.send({
photos: [
await seed.dogPhoto.toReqJSON(),
await seed.catPhoto.toReqJSON(),
],
} as IPhotosDeleteBody)
.expect(200);
expect(response.body.error).to.be.false;
const dbPhoto1 = await Photo.findOne(seed.dogPhoto.id);
expect(dbPhoto1).to.be.undefined;
const dbPhoto2 = await Photo.findOne(seed.catPhoto.id);
expect(dbPhoto2).to.be.undefined;
try {
await fs.access(photo1Path, fsConstants.F_OK);
await fs.access(photo1SmallThumbPath, fsConstants.F_OK);
await fs.access(photo2Path, fsConstants.F_OK);
await fs.access(photo2SmallThumbPath, fsConstants.F_OK);
assert(false);
} catch (e) {
assert(true);
}
});
});