mirror of
https://github.com/usatiuk/photos.git
synced 2025-10-28 23:37:48 +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 * 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user