show thumbnails instead of full size

This commit is contained in:
2020-10-15 13:59:40 +03:00
committed by Stepan Usatiuk
parent 3572db7e23
commit 0b44f10a56
6 changed files with 111 additions and 7 deletions

View File

@@ -1,11 +1,18 @@
import { Card, ContextMenuTarget, Menu, MenuItem } from "@blueprintjs/core";
import {
Card,
ContextMenuTarget,
Menu,
MenuItem,
Spinner,
} from "@blueprintjs/core";
import * as React from "react";
import { IPhotoReqJSON } from "~../../src/entity/Photo";
import { getPhotoImgPath } from "~redux/api/photos";
import { getPhotoImgPath, getPhotoThumbPath } from "~redux/api/photos";
import { showDeletionToast } from "~AppToaster";
import { Dispatch } from "redux";
import { photoDeleteCancel, photoDeleteStart } from "~redux/photos/actions";
import { connect } from "react-redux";
import { LoadingStub } from "~LoadingStub";
export interface IPhotoCardComponentProps {
photo: IPhotoReqJSON;
@@ -35,6 +42,7 @@ export class PhotoCardComponent extends React.PureComponent<
}
*/
public render(): JSX.Element {
const isUploaded = this.props.photo.uploaded;
return (
<Card
className="photoCard"
@@ -45,7 +53,11 @@ export class PhotoCardComponent extends React.PureComponent<
}
*/
>
<img src={getPhotoImgPath(this.props.photo)}></img>
{isUploaded ? (
<img src={getPhotoThumbPath(this.props.photo, 512)}></img>
) : (
<Spinner />
)}
</Card>
);
}

View File

@@ -12,6 +12,12 @@ export function getPhotoImgPath(photo: IPhotoReqJSON): string {
return `${apiRoot}/photos/showByID/${photo.id}/${photo.accessToken}`;
}
export function getPhotoThumbPath(photo: IPhotoReqJSON, size: number): string {
return `${apiRoot}/photos/showByID/${photo.id}/${
photo.accessToken
}?size=${size.toString()}`;
}
export async function fetchPhotosList(): Promise<IPhotosListRespBody> {
return fetchJSONAuth("/photos/list", "GET");
}

View File

@@ -21,12 +21,15 @@ import {
isAlphanumeric,
IsAlphanumeric,
IsHash,
IsIn,
IsMimeType,
isNumber,
Length,
Matches,
validateOrReject,
} from "class-validator";
import { config } from "~config";
import { resizeTo } from "~util";
export interface IPhotoJSON {
id: number;
@@ -37,12 +40,15 @@ export interface IPhotoJSON {
createdAt: number;
editedAt: number;
shotAt: number;
uploaded: boolean;
}
export interface IPhotoReqJSON extends IPhotoJSON {
accessToken: string;
}
export const ThumbSizes = ["512", "1024", "2048"];
@Entity()
@Index(["hash", "size", "user"], { unique: true })
export class Photo extends BaseEntity {
@@ -63,6 +69,9 @@ export class Photo extends BaseEntity {
@IsMimeType()
public format: string;
@Column({ type: "set", enum: ThumbSizes, default: [] })
public generatedThumbs: string[];
@Column({ type: "varchar", length: 500 })
public accessToken: string;
@@ -87,10 +96,20 @@ export class Photo extends BaseEntity {
}`;
}
private getThumbFileName(size: number): string {
return `${this.user.id.toString()}-${this.hash}-${this.size}-${size}.${
mime.extension(this.format) as string
}`;
}
public getPath(): string {
return path.join(this.user.getDataPath(), this.getFileName());
}
private getThumbPath(size: number): string {
return path.join(this.user.getDataPath(), this.getThumbFileName(size));
}
@BeforeInsert()
async beforeInsertValidate(): Promise<void> {
return validateOrReject(this);
@@ -105,6 +124,12 @@ export class Photo extends BaseEntity {
async cleanupFiles(): Promise<void> {
try {
await fs.unlink(this.getPath());
await Promise.all(
this.generatedThumbs.map(
async (size) =>
await fs.unlink(this.getThumbPath(parseInt(size))),
),
);
} catch (e) {
if (e.code !== "ENOENT") {
throw e;
@@ -121,6 +146,26 @@ export class Photo extends BaseEntity {
}
}
private async generateThumbnail(size: number): Promise<void> {
if (!(await this.isUploaded())) {
return;
}
await resizeTo(this.getPath(), this.getThumbPath(size), size);
this.generatedThumbs.push(size.toString());
await this.save();
}
public async getReadyThumbnailPath(size: number): Promise<string> {
if (!ThumbSizes.includes(size.toString())) {
throw new Error("Wrong thumbnail size");
}
const path = this.getThumbPath(size);
if (!this.generatedThumbs.includes(size.toString())) {
await this.generateThumbnail(size);
}
return path;
}
constructor(user: User, hash: string, size: string, format: string) {
super();
this.createdAt = new Date();
@@ -132,6 +177,7 @@ export class Photo extends BaseEntity {
this.format = format;
this.size = size;
this.user = user;
this.generatedThumbs = [];
}
public async getJWTToken(): Promise<string> {
@@ -141,7 +187,7 @@ export class Photo extends BaseEntity {
if (tokenExpiryOld - now - 60 * 10 * 1000 > 0) {
return this.accessToken;
} else {
const token = jwt.sign(this.toJSON(), config.jwtSecret, {
const token = jwt.sign(await this.toJSON(), config.jwtSecret, {
expiresIn: "1h",
algorithm: "HS256",
});
@@ -152,7 +198,10 @@ export class Photo extends BaseEntity {
}
}
public toJSON(): IPhotoJSON {
public async toJSON(): Promise<IPhotoJSON> {
if (!isNumber(this.user.id)) {
throw new Error("User not loaded");
}
return {
id: this.id,
user: this.user.id,
@@ -162,11 +211,12 @@ export class Photo extends BaseEntity {
createdAt: this.createdAt.getTime(),
editedAt: this.editedAt.getTime(),
shotAt: this.shotAt.getTime(),
uploaded: await this.isUploaded(),
};
}
public async toReqJSON(): Promise<IPhotoReqJSON> {
const token = await this.getJWTToken();
return { ...this.toJSON(), accessToken: token };
return { ...(await this.toJSON()), accessToken: token };
}
}

View File

@@ -30,7 +30,7 @@ photosRouter.post("/photos/new", async (ctx) => {
return;
}
const photo = new Photo(user.id, hash, size, format);
const photo = new Photo(user, hash, size, format);
try {
await photo.save();
@@ -241,6 +241,12 @@ photosRouter.get("/photos/showByID/:id/:token", async (ctx) => {
return;
}
if (ctx.request.query["size"]) {
const size = parseInt(ctx.request.query["size"]);
await send(ctx, await photo.getReadyThumbnailPath(size));
return;
}
await send(ctx, photo.getPath());
});
@@ -266,6 +272,12 @@ photosRouter.get("/photos/showByID/:id", async (ctx) => {
return;
}
if (ctx.request.query["size"]) {
const size = parseInt(ctx.request.query["size"]);
await send(ctx, await photo.getReadyThumbnailPath(size));
return;
}
await send(ctx, photo.getPath());
});

View File

@@ -82,6 +82,18 @@ describe("photos", function () {
);
});
it("should show a thumbnail", async function () {
const response = await request(callback)
.get(`/photos/showByID/${seed.dogPhoto.id}?size=512`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(200);
expect(parseInt(response.header["content-length"])).to.be.lessThan(
dogFileSize,
);
});
it("should show a photo using access token", async function () {
const listResp = await request(callback)
.get(`/photos/list`)
@@ -524,6 +536,9 @@ describe("photos", function () {
it("should delete a photo", async function () {
const photoPath = seed.dogPhoto.getPath();
const photoSmallThumbPath = await seed.dogPhoto.getReadyThumbnailPath(
512,
);
const response = await request(callback)
.delete(`/photos/byID/${seed.dogPhoto.id}`)
.set({
@@ -537,6 +552,7 @@ describe("photos", function () {
try {
await fs.access(photoPath, fsConstants.F_OK);
await fs.access(photoSmallThumbPath, fsConstants.F_OK);
assert(false);
} catch (e) {
assert(true);

View File

@@ -18,6 +18,14 @@ export async function getSize(file: string): Promise<string> {
return `${metadata.width}x${metadata.height}`;
}
export async function resizeTo(
inPath: string,
outPath: string,
size: number,
): Promise<void> {
await sharp(inPath).resize(size, size).withMetadata().toFile(outPath);
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
export const getHashSync: (file: string) => string = deasync(getHash);
// eslint-disable-next-line @typescript-eslint/no-misused-promises