mirror of
https://github.com/usatiuk/photos.git
synced 2025-10-28 15:27:49 +01:00
show thumbnails instead of full size
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user