@@ -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(
diff --git a/frontend/src/Photos/PhotoCard.tsx b/frontend/src/Photos/PhotoCard.tsx
index b9cbc33..4c8da6b 100644
--- a/frontend/src/Photos/PhotoCard.tsx
+++ b/frontend/src/Photos/PhotoCard.tsx
@@ -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) => 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)),
};
}
diff --git a/frontend/src/redux/api/photos.ts b/frontend/src/redux/api/photos.ts
index 98f8e26..5644acb 100644
--- a/frontend/src/redux/api/photos.ts
+++ b/frontend/src/redux/api/photos.ts
@@ -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 {
- return fetchJSONAuth(`/photos/byID/${photo.id}`, "DELETE");
+export async function deletePhotos(
+ photos: IPhotoReqJSON[],
+): Promise {
+ return fetchJSONAuth(`/photos/delete`, "POST", { photos });
}
diff --git a/frontend/src/redux/photos/actions.ts b/frontend/src/redux/photos/actions.ts
index a43b657..dbc64c9 100644
--- a/frontend/src/redux/photos/actions.ts
+++ b/frontend/src/redux/photos/actions.ts
@@ -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
diff --git a/frontend/src/redux/photos/reducer.ts b/frontend/src/redux/photos/reducer.ts
index 340fdf1..f9737aa 100644
--- a/frontend/src/redux/photos/reducer.ts
+++ b/frontend/src/redux/photos/reducer.ts
@@ -196,15 +196,18 @@ export const photosReducer: Reducer = (
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 = (
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;
diff --git a/frontend/src/redux/photos/sagas.ts b/frontend/src/redux/photos/sagas.ts
index 7ea645e..4b632a8 100644
--- a/frontend/src/redux/photos/sagas.ts
+++ b/frontend/src/redux/photos/sagas.ts
@@ -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),
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index eaaba3f..2bcd6fc 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -9,7 +9,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
- "noImplicitAny": true,
+ "noImplicitAny": false,
"allowSyntheticDefaultImports": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
diff --git a/src/routes/photos.ts b/src/routes/photos.ts
index 11c1213..d861360 100644
--- a/src/routes/photos.ts
+++ b/src/routes/photos.ts
@@ -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;
+export type IPhotoByIDDeleteRespBody = IAPIResponse;
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;
+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;
+ }
});
diff --git a/src/tests/integration/photos.test.ts b/src/tests/integration/photos.test.ts
index b5f0407..268d765 100644
--- a/src/tests/integration/photos.test.ts
+++ b/src/tests/integration/photos.test.ts
@@ -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);
+ }
+ });
});