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 * as React from "react";
import { IPhotoReqJSON } from "~../../src/entity/Photo"; import { IPhotoReqJSON } from "~../../src/entity/Photo";
import { getPhotoImgPath } from "~redux/api/photos"; import { getPhotoImgPath, getPhotoThumbPath } from "~redux/api/photos";
import { showDeletionToast } from "~AppToaster"; import { showDeletionToast } from "~AppToaster";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import { photoDeleteCancel, photoDeleteStart } from "~redux/photos/actions"; import { photoDeleteCancel, photoDeleteStart } from "~redux/photos/actions";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { LoadingStub } from "~LoadingStub";
export interface IPhotoCardComponentProps { export interface IPhotoCardComponentProps {
photo: IPhotoReqJSON; photo: IPhotoReqJSON;
@@ -35,6 +42,7 @@ export class PhotoCardComponent extends React.PureComponent<
} }
*/ */
public render(): JSX.Element { public render(): JSX.Element {
const isUploaded = this.props.photo.uploaded;
return ( return (
<Card <Card
className="photoCard" 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> </Card>
); );
} }

View File

@@ -12,6 +12,12 @@ export function getPhotoImgPath(photo: IPhotoReqJSON): string {
return `${apiRoot}/photos/showByID/${photo.id}/${photo.accessToken}`; 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> { export async function fetchPhotosList(): Promise<IPhotosListRespBody> {
return fetchJSONAuth("/photos/list", "GET"); return fetchJSONAuth("/photos/list", "GET");
} }

View File

@@ -21,12 +21,15 @@ import {
isAlphanumeric, isAlphanumeric,
IsAlphanumeric, IsAlphanumeric,
IsHash, IsHash,
IsIn,
IsMimeType, IsMimeType,
isNumber,
Length, Length,
Matches, Matches,
validateOrReject, validateOrReject,
} from "class-validator"; } from "class-validator";
import { config } from "~config"; import { config } from "~config";
import { resizeTo } from "~util";
export interface IPhotoJSON { export interface IPhotoJSON {
id: number; id: number;
@@ -37,12 +40,15 @@ export interface IPhotoJSON {
createdAt: number; createdAt: number;
editedAt: number; editedAt: number;
shotAt: number; shotAt: number;
uploaded: boolean;
} }
export interface IPhotoReqJSON extends IPhotoJSON { export interface IPhotoReqJSON extends IPhotoJSON {
accessToken: string; accessToken: string;
} }
export const ThumbSizes = ["512", "1024", "2048"];
@Entity() @Entity()
@Index(["hash", "size", "user"], { unique: true }) @Index(["hash", "size", "user"], { unique: true })
export class Photo extends BaseEntity { export class Photo extends BaseEntity {
@@ -63,6 +69,9 @@ export class Photo extends BaseEntity {
@IsMimeType() @IsMimeType()
public format: string; public format: string;
@Column({ type: "set", enum: ThumbSizes, default: [] })
public generatedThumbs: string[];
@Column({ type: "varchar", length: 500 }) @Column({ type: "varchar", length: 500 })
public accessToken: string; 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 { public getPath(): string {
return path.join(this.user.getDataPath(), this.getFileName()); return path.join(this.user.getDataPath(), this.getFileName());
} }
private getThumbPath(size: number): string {
return path.join(this.user.getDataPath(), this.getThumbFileName(size));
}
@BeforeInsert() @BeforeInsert()
async beforeInsertValidate(): Promise<void> { async beforeInsertValidate(): Promise<void> {
return validateOrReject(this); return validateOrReject(this);
@@ -105,6 +124,12 @@ export class Photo extends BaseEntity {
async cleanupFiles(): Promise<void> { async cleanupFiles(): Promise<void> {
try { try {
await fs.unlink(this.getPath()); await fs.unlink(this.getPath());
await Promise.all(
this.generatedThumbs.map(
async (size) =>
await fs.unlink(this.getThumbPath(parseInt(size))),
),
);
} catch (e) { } catch (e) {
if (e.code !== "ENOENT") { if (e.code !== "ENOENT") {
throw e; 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) { constructor(user: User, hash: string, size: string, format: string) {
super(); super();
this.createdAt = new Date(); this.createdAt = new Date();
@@ -132,6 +177,7 @@ export class Photo extends BaseEntity {
this.format = format; this.format = format;
this.size = size; this.size = size;
this.user = user; this.user = user;
this.generatedThumbs = [];
} }
public async getJWTToken(): Promise<string> { public async getJWTToken(): Promise<string> {
@@ -141,7 +187,7 @@ export class Photo extends BaseEntity {
if (tokenExpiryOld - now - 60 * 10 * 1000 > 0) { if (tokenExpiryOld - now - 60 * 10 * 1000 > 0) {
return this.accessToken; return this.accessToken;
} else { } else {
const token = jwt.sign(this.toJSON(), config.jwtSecret, { const token = jwt.sign(await this.toJSON(), config.jwtSecret, {
expiresIn: "1h", expiresIn: "1h",
algorithm: "HS256", 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 { return {
id: this.id, id: this.id,
user: this.user.id, user: this.user.id,
@@ -162,11 +211,12 @@ export class Photo extends BaseEntity {
createdAt: this.createdAt.getTime(), createdAt: this.createdAt.getTime(),
editedAt: this.editedAt.getTime(), editedAt: this.editedAt.getTime(),
shotAt: this.shotAt.getTime(), shotAt: this.shotAt.getTime(),
uploaded: await this.isUploaded(),
}; };
} }
public async toReqJSON(): Promise<IPhotoReqJSON> { public async toReqJSON(): Promise<IPhotoReqJSON> {
const token = await this.getJWTToken(); 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; return;
} }
const photo = new Photo(user.id, hash, size, format); const photo = new Photo(user, hash, size, format);
try { try {
await photo.save(); await photo.save();
@@ -241,6 +241,12 @@ photosRouter.get("/photos/showByID/:id/:token", async (ctx) => {
return; 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()); await send(ctx, photo.getPath());
}); });
@@ -266,6 +272,12 @@ photosRouter.get("/photos/showByID/:id", async (ctx) => {
return; 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()); 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 () { it("should show a photo using access token", async function () {
const listResp = await request(callback) const listResp = await request(callback)
.get(`/photos/list`) .get(`/photos/list`)
@@ -524,6 +536,9 @@ describe("photos", function () {
it("should delete a photo", async function () { it("should delete a photo", async function () {
const photoPath = seed.dogPhoto.getPath(); const photoPath = seed.dogPhoto.getPath();
const photoSmallThumbPath = await seed.dogPhoto.getReadyThumbnailPath(
512,
);
const response = await request(callback) const response = await request(callback)
.delete(`/photos/byID/${seed.dogPhoto.id}`) .delete(`/photos/byID/${seed.dogPhoto.id}`)
.set({ .set({
@@ -537,6 +552,7 @@ describe("photos", function () {
try { try {
await fs.access(photoPath, fsConstants.F_OK); await fs.access(photoPath, fsConstants.F_OK);
await fs.access(photoSmallThumbPath, fsConstants.F_OK);
assert(false); assert(false);
} catch (e) { } catch (e) {
assert(true); assert(true);

View File

@@ -18,6 +18,14 @@ export async function getSize(file: string): Promise<string> {
return `${metadata.width}x${metadata.height}`; 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 // eslint-disable-next-line @typescript-eslint/no-misused-promises
export const getHashSync: (file: string) => string = deasync(getHash); export const getHashSync: (file: string) => string = deasync(getHash);
// eslint-disable-next-line @typescript-eslint/no-misused-promises // eslint-disable-next-line @typescript-eslint/no-misused-promises