a badly looking image cards thing

This commit is contained in:
2020-10-14 20:50:30 +03:00
committed by Stepan Usatiuk
parent 07ccc56636
commit c20642dba4
17 changed files with 364 additions and 65 deletions

View File

@@ -54,6 +54,7 @@
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/unbound-method": "off"
"@typescript-eslint/unbound-method": "off",
"react/prop-types": "off"
}
}

View File

@@ -1,10 +1,12 @@
import { Spinner } from "@blueprintjs/core";
import * as React from "react";
export function LoadingStub() {
return (
<div className="loadingWrapper">
<Spinner />
</div>
);
export interface ILoadingStubProps {
spinner?: boolean;
}
export const LoadingStub: React.FunctionComponent<ILoadingStubProps> = (
props,
) => {
return <div className="loadingWrapper">{props.spinner && <Spinner />}</div>;
};

View File

@@ -3,24 +3,53 @@ import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { IAppState } from "~redux/reducers";
import { photosLoadStart } from "~redux/photos/actions";
import { IPhotoReqJSON } from "~../../src/entity/Photo";
import { LoadingStub } from "~LoadingStub";
import { PhotoCard } from "./PhotoCard";
export interface IOverviewComponentProps {
photos: IPhotoReqJSON[] | null;
fetching: boolean;
spinner: boolean;
fetchingError: string | null;
fetchingSpinner: boolean;
fetchPhotos: () => void;
}
export function OverviewComponent() {
return <div id="overview">Overview!</div>;
}
export const OverviewComponent: React.FunctionComponent<IOverviewComponentProps> = (
props,
) => {
if (!props.photos && !props.fetching) {
props.fetchPhotos();
}
if (!props.photos) {
return <LoadingStub spinner={props.fetchingSpinner} />;
}
const photos = props.photos.map((photo) => (
<PhotoCard key={photo.id} photo={photo} />
));
return (
<div id="overview">
{" "}
<div className="list">{photos}</div>
</div>
);
};
function mapStateToProps(state: IAppState) {
return {};
return {
photos: state.photos.photos,
fetching: state.photos.fetching,
fetchingError: state.photos.fetchingError,
fetchingSpinner: state.photos.fetchingSpinner,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {};
return { fetchPhotos: () => dispatch(photosLoadStart()) };
}
export const Overview = connect(

View File

@@ -0,0 +1,16 @@
import { Card } from "@blueprintjs/core";
import * as React from "react";
import { IPhotoReqJSON } from "~../../src/entity/Photo";
import { getPhotoImgPath } from "~redux/api/photos";
export interface IPhotoCardProps {
photo: IPhotoReqJSON;
}
export const PhotoCard: React.FunctionComponent<IPhotoCardProps> = (props) => {
return (
<Card className="photoCard">
<img src={getPhotoImgPath(props.photo)}></img>
</Card>
);
};

View File

@@ -3,9 +3,35 @@
#overview {
display: flex;
flex-direction: column;
.list {
display: flex;
flex-shrink: 0;
flex-grow: 0;
flex-wrap: wrap;
// 400px is the minimal width for 2 cards to fit
@media (max-width: 400px) {
justify-content: center;
}
.photoCard {
transition: 0.3s;
user-select: none;
height: 15rem;
width: 10.5rem;
margin: 1rem;
padding: 0rem;
overflow: hidden;
img {
height: auto;
width: 100%;
}
}
}
}
.bp3-dark {
#overview {
}
}
#overview {}
}

View File

@@ -1,15 +1,26 @@
import * as React from "react";
import { shallow } from "enzyme";
import { OverviewComponent } from "../Overview";
import { IOverviewComponentProps, OverviewComponent } from "../Overview";
afterEach(() => {
jest.restoreAllMocks();
});
const overviewComponentDefaultProps: IOverviewComponentProps = {
photos: null,
fetching: false,
fetchingError: null,
fetchingSpinner: false,
fetchPhotos: jest.fn(),
};
describe("<Overview />", () => {
it("should not crash", () => {
const wrapper = shallow(<OverviewComponent />);
const wrapper = shallow(
<OverviewComponent {...overviewComponentDefaultProps} />,
);
expect(wrapper.contains("Overview!")).toBeTruthy();
});
});

View File

@@ -1,25 +1,27 @@
import { IUserAuthJSON } from "~../../src/entity/User";
import { IAPIResponse } from "~../../src/types";
import {
IUserLoginRespBody,
IUserSignupRespBody,
} from "~../../src/routes/users";
import { fetchJSON } from "../utils";
export async function login(
username: string,
password: string,
): Promise<IAPIResponse<IUserAuthJSON>> {
): Promise<IUserLoginRespBody> {
return (fetchJSON("/users/login", "POST", {
username,
password,
}) as unknown) as Promise<IAPIResponse<IUserAuthJSON>>;
}) as unknown) as Promise<IUserLoginRespBody>;
}
export async function signup(
username: string,
password: string,
email: string,
): Promise<IAPIResponse<IUserAuthJSON>> {
): Promise<IUserSignupRespBody> {
return (fetchJSON("/users/signup", "POST", {
username,
password,
email,
}) as unknown) as Promise<IAPIResponse<IUserAuthJSON>>;
}) as unknown) as Promise<IUserSignupRespBody>;
}

View File

@@ -0,0 +1,14 @@
import { IPhotoReqJSON } from "~../../src/entity/Photo";
import { IPhotosListRespBody } from "~../../src/routes/photos";
import { apiRoot } from "~env";
import { fetchJSONAuth } from "./utils";
export function getPhotoImgPath(photo: IPhotoReqJSON): string {
return `${apiRoot}/photos/showByID/${photo.id}/${photo.accessToken}`;
}
export async function fetchPhotosList(): Promise<IPhotosListRespBody> {
return (fetchJSONAuth("/photos/list", "GET") as unknown) as Promise<
IPhotosListRespBody
>;
}

View File

@@ -1,15 +1,16 @@
import { fetchJSON, fetchJSONAuth } from "../utils";
import { IAPIResponse } from "~/../../src/types";
import { IUserAuthJSON, IUserJSON } from "~../../src/entity/User";
import { fetchJSONAuth } from "../utils";
import { IUserEditRespBody, IUserGetRespBody } from "~../../src/routes/users";
export async function fetchUser(): Promise<IAPIResponse<IUserJSON>> {
export async function fetchUser(): Promise<IUserGetRespBody> {
return (fetchJSONAuth("/users/user", "GET") as unknown) as Promise<
IAPIResponse<IUserAuthJSON>
IUserGetRespBody
>;
}
export async function changeUserPassword(newPassword: string) {
export async function changeUserPassword(
newPassword: string,
): Promise<IUserEditRespBody> {
return (fetchJSONAuth("/users/edit", "POST", {
password: newPassword,
}) as unknown) as Promise<IAPIResponse<IUserAuthJSON>>;
}) as unknown) as Promise<IUserEditRespBody>;
}

View File

@@ -0,0 +1,51 @@
import { Action } from "redux";
import { IPhotoReqJSON, Photo } from "~../../src/entity/Photo";
export enum PhotoTypes {
PHOTOS_LOAD_START = "PHOTOS_LOAD",
PHOTOS_LOAD_SUCCESS = "PHOTOS_LOAD_SUCCESS",
PHOTOS_LOAD_FAIL = "PHOTOS_LOAD_FAIL",
PHOTOS_START_FETCHING_SPINNER = "PHOTOS_START_FETCHING_SPINNER",
}
export interface IPhotosLoadStartAction extends Action {
type: PhotoTypes.PHOTOS_LOAD_START;
}
export interface IPhotosLoadSuccessAction extends Action {
type: PhotoTypes.PHOTOS_LOAD_SUCCESS;
photos: IPhotoReqJSON[];
}
export interface IPhotosLoadFailAction extends Action {
type: PhotoTypes.PHOTOS_LOAD_FAIL;
error: string;
}
export interface IPhotosStartFetchingSpinner extends Action {
type: PhotoTypes.PHOTOS_START_FETCHING_SPINNER;
}
export function photosLoadStart(): IPhotosLoadStartAction {
return { type: PhotoTypes.PHOTOS_LOAD_START };
}
export function photosLoadSuccess(
photos: IPhotoReqJSON[],
): IPhotosLoadSuccessAction {
return { type: PhotoTypes.PHOTOS_LOAD_SUCCESS, photos };
}
export function photosLoadFail(error: string): IPhotosLoadFailAction {
return { type: PhotoTypes.PHOTOS_LOAD_FAIL, error };
}
export function photosStartFetchingSpinner(): IPhotosStartFetchingSpinner {
return { type: PhotoTypes.PHOTOS_START_FETCHING_SPINNER };
}
export type PhotoAction =
| IPhotosLoadStartAction
| IPhotosLoadFailAction
| IPhotosLoadSuccessAction
| IPhotosStartFetchingSpinner;

View File

@@ -0,0 +1,40 @@
import { Reducer } from "redux";
import { IPhotoReqJSON } from "~../../src/entity/Photo";
import { PhotoAction, PhotoTypes } from "./actions";
export interface IPhotosState {
photos: IPhotoReqJSON[] | null;
fetching: boolean;
fetchingError: string | null;
fetchingSpinner: boolean;
}
const defaultPhotosState: IPhotosState = {
photos: null,
fetching: false,
fetchingError: null,
fetchingSpinner: false,
};
export const photosReducer: Reducer<IPhotosState, PhotoAction> = (
state: IPhotosState = defaultPhotosState,
action: PhotoAction,
) => {
switch (action.type) {
case PhotoTypes.PHOTOS_LOAD_START:
return {
...defaultPhotosState,
fetching: true,
fetchingSpinner: false,
};
case PhotoTypes.PHOTOS_START_FETCHING_SPINNER:
return { ...state, fetchingSpinner: true };
case PhotoTypes.PHOTOS_LOAD_SUCCESS:
return { ...defaultPhotosState, photos: action.photos };
case PhotoTypes.PHOTOS_LOAD_FAIL:
return { ...defaultPhotosState, fetchingError: action.error };
default:
return state;
}
return state;
};

View File

@@ -0,0 +1,52 @@
import {
all,
call,
cancel,
delay,
fork,
put,
race,
takeLatest,
} from "redux-saga/effects";
import { fetchPhotosList } from "~redux/api/photos";
import {
photosLoadFail,
photosLoadSuccess,
photosStartFetchingSpinner,
PhotoTypes,
} from "./actions";
function* startSpinner() {
yield delay(300);
yield put(photosStartFetchingSpinner());
}
function* photosLoad() {
try {
const spinner = yield fork(startSpinner);
const { response, timeout } = yield race({
response: call(fetchPhotosList),
timeout: delay(10000),
});
yield cancel(spinner);
if (timeout) {
yield put(photosLoadFail("Timeout"));
return;
}
if (response.data) {
const photos = response.data;
yield put(photosLoadSuccess(photos));
} else {
yield put(photosLoadFail(response.error));
}
} catch (e) {
yield put(photosLoadFail("Internal error"));
}
}
export function* photosSaga() {
yield all([takeLatest(PhotoTypes.PHOTOS_LOAD_START, photosLoad)]);
}

View File

@@ -7,12 +7,14 @@ import {
ILocalSettingsState,
localSettingsReducer,
} from "./localSettings/reducer";
import { IPhotosState, photosReducer } from "./photos/reducer";
import { IUserState, userReducer } from "./user/reducer";
export interface IAppState {
auth: IAuthState & PersistPartial;
user: IUserState;
localSettings: ILocalSettingsState & PersistPartial;
photos: IPhotosState;
}
const authPersistConfig = {
@@ -33,4 +35,5 @@ export const rootReducer = combineReducers({
localSettingsPersistConfig,
localSettingsReducer as any,
),
photos: photosReducer,
});

View File

@@ -6,6 +6,7 @@ import { rootReducer } from "~redux/reducers";
import { setToken } from "./api/utils";
import { authSaga } from "./auth/sagas";
import { photosSaga } from "./photos/sagas";
import { getUser } from "./user/actions";
import { userSaga } from "./user/sagas";
@@ -26,3 +27,4 @@ export const persistor = persistStore(store, null, () => {
sagaMiddleware.run(authSaga);
sagaMiddleware.run(userSaga);
sagaMiddleware.run(photosSaga);

View File

@@ -2,6 +2,7 @@ import * as path from "path";
import * as fs from "fs/promises";
import * as mime from "mime-types";
import { constants as fsConstants } from "fs";
import * as jwt from "jsonwebtoken";
import {
AfterRemove,
@@ -25,6 +26,7 @@ import {
Matches,
validateOrReject,
} from "class-validator";
import { config } from "~config";
export interface IPhotoJSON {
id: number;
@@ -36,6 +38,10 @@ export interface IPhotoJSON {
editedAt: number;
}
export interface IPhotoReqJSON extends IPhotoJSON {
accessToken: string;
}
@Entity()
export class Photo extends BaseEntity {
@PrimaryGeneratedColumn()
@@ -114,6 +120,13 @@ export class Photo extends BaseEntity {
this.user = user;
}
public getJWTToken(): string {
return jwt.sign(this.toJSON(), config.jwtSecret, {
expiresIn: "1h",
algorithm: "HS256",
});
}
public toJSON(): IPhotoJSON {
return {
id: this.id,
@@ -125,4 +138,17 @@ export class Photo extends BaseEntity {
editedAt: this.editedAt.getTime(),
};
}
public toReqJSON(): IPhotoReqJSON {
return {
id: this.id,
user: this.user.id,
hash: this.hash,
size: this.size,
format: this.format,
createdAt: this.createdAt.getTime(),
editedAt: this.editedAt.getTime(),
accessToken: this.getJWTToken(),
};
}
}

View File

@@ -1,5 +1,5 @@
import * as Router from "@koa/router";
import { IPhotoJSON, Photo } from "~entity/Photo";
import { IPhotoReqJSON, Photo } from "~entity/Photo";
import { User } from "~entity/User";
import { IAPIResponse } from "~types";
import * as fs from "fs/promises";
@@ -15,7 +15,7 @@ export interface IPhotosNewPostBody {
size: string | undefined;
format: string | undefined;
}
export type IPhotosNewRespBody = IAPIResponse<IPhotoJSON>;
export type IPhotosNewRespBody = IAPIResponse<IPhotoReqJSON>;
photosRouter.post("/photos/new", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
@@ -40,11 +40,11 @@ photosRouter.post("/photos/new", async (ctx) => {
ctx.body = {
error: false,
data: photo.toJSON(),
data: photo.toReqJSON(),
} as IPhotosNewRespBody;
});
export type IPhotosUploadRespBody = IAPIResponse<IPhotoJSON>;
export type IPhotosUploadRespBody = IAPIResponse<IPhotoReqJSON>;
photosRouter.post("/photos/upload/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
@@ -101,14 +101,14 @@ photosRouter.post("/photos/upload/:id", async (ctx) => {
}
ctx.body = {
error: false,
data: photo.toJSON(),
data: photo.toReqJSON(),
} as IPhotosUploadRespBody;
});
/**
export interface IPhotosByIDPatchBody {
}
export type IPhotosByIDPatchRespBody = IAPIResponse<IPhotoJSON>;
export type IPhotosByIDPatchRespBody = IAPIResponse<IPhotoReqJSON>;
photosRouter.patch("/photos/byID/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
@@ -143,12 +143,12 @@ photosRouter.patch("/photos/byID/:id", async (ctx) => {
ctx.body = {
error: false,
data: photo.toJSON(),
data: photo.toReqJSON(),
};
});
*/
export type IPhotosListRespBody = IAPIResponse<IPhotoJSON[]>;
export type IPhotosListRespBody = IAPIResponse<IPhotoReqJSON[]>;
photosRouter.get("/photos/list", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
@@ -160,11 +160,11 @@ photosRouter.get("/photos/list", async (ctx) => {
ctx.body = {
error: false,
data: photos.map((photo) => photo.toJSON()),
data: photos.map((photo) => photo.toReqJSON()),
} as IPhotosListRespBody;
});
export type IPhotosByIDGetRespBody = IAPIResponse<IPhotoJSON>;
export type IPhotosByIDGetRespBody = IAPIResponse<IPhotoReqJSON>;
photosRouter.get("/photos/byID/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
@@ -189,7 +189,7 @@ photosRouter.get("/photos/byID/:id", async (ctx) => {
ctx.body = {
error: false,
data: photo.toJSON(),
data: photo.toReqJSON(),
} as IPhotosByIDGetRespBody;
});
@@ -205,13 +205,13 @@ photosRouter.get("/photos/showByID/:id/:token", async (ctx) => {
}
try {
jwt.verify(token, config.jwtSecret) as IPhotoJSON;
jwt.verify(token, config.jwtSecret) as IPhotoReqJSON;
} catch (e) {
ctx.throw(401);
}
const photoJson = jwt.decode(token) as IPhotoJSON;
const { user } = photoJson;
const photoReqJSON = jwt.decode(token) as IPhotoReqJSON;
const { user } = photoReqJSON;
const photo = await Photo.findOne({
id,
@@ -274,10 +274,7 @@ photosRouter.get("/photos/getShowByIDToken/:id", async (ctx) => {
return;
}
const token = jwt.sign(photo.toJSON(), config.jwtSecret, {
expiresIn: "1h",
algorithm: "HS256",
});
const token = photo.getJWTToken();
ctx.body = { error: false, data: token } as IPhotosGetShowTokenByID;
});

View File

@@ -3,13 +3,14 @@ import { connect } from "config/database";
import * as request from "supertest";
import { getConnection } from "typeorm";
import { app } from "~app";
import { Photo, IPhotoJSON } from "~entity/Photo";
import { IPhotosNewPostBody } from "~routes/photos";
import { Photo, IPhotoReqJSON } from "~entity/Photo";
import { IPhotosListRespBody, IPhotosNewPostBody } from "~routes/photos";
import * as fs from "fs/promises";
import { constants as fsConstants } from "fs";
import * as jwt from "jsonwebtoken";
import {
catFileSize,
catPath,
dogFileSize,
dogFormat,
@@ -51,9 +52,9 @@ describe("photos", function () {
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoJSON;
const photo = response.body.data as IPhotoReqJSON;
const usedPhoto = seed.dogPhoto.toJSON();
const usedPhoto = seed.dogPhoto.toReqJSON();
expect(photo).to.deep.equal(usedPhoto);
});
@@ -82,6 +83,31 @@ describe("photos", function () {
});
it("should show a photo using access token", async function () {
const listResp = await request(callback)
.get(`/photos/list`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(200);
const listRespBody = listResp.body as IPhotosListRespBody;
if (listRespBody.error !== false) {
expect(listResp.body.error).to.be.false;
return;
}
const photos = listRespBody.data;
expect(photos.length).to.be.equal(2);
const listAnyResp = await request(callback)
.get(`/photos/showByID/${photos[0].id}/${photos[0].accessToken}`)
.expect(200);
expect(parseInt(listAnyResp.header["content-length"])).to.be.oneOf([
dogFileSize,
catFileSize,
]);
const getTokenResp = await request(callback)
.get(`/photos/getShowByIDToken/${seed.dogPhoto.id}`)
.set({
@@ -100,7 +126,7 @@ describe("photos", function () {
);
const tokenSelfSigned = jwt.sign(
seed.dogPhoto.toJSON(),
seed.dogPhoto.toReqJSON(),
config.jwtSecret,
{
expiresIn: "1m",
@@ -116,7 +142,7 @@ describe("photos", function () {
});
it("should not show a photo using expired access token", async function () {
const token = jwt.sign(seed.dogPhoto.toJSON(), config.jwtSecret, {
const token = jwt.sign(seed.dogPhoto.toReqJSON(), config.jwtSecret, {
expiresIn: "0s",
});
@@ -152,7 +178,7 @@ describe("photos", function () {
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoJSON;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.hash).to.be.equal(dogHash);
const dbPhoto = await Photo.findOneOrFail({
@@ -202,7 +228,7 @@ describe("photos", function () {
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoJSON;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.hash).to.be.equal(dogHash);
const dbPhoto = await Photo.findOneOrFail({
@@ -261,7 +287,7 @@ describe("photos", function () {
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoJSON;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.hash).to.be.equal(dogHash);
const dbPhoto = await Photo.findOneOrFail({
@@ -307,7 +333,7 @@ describe("photos", function () {
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoJSON;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.hash).to.be.equal(dogHash);
const dbPhoto = await Photo.findOneOrFail({
@@ -345,7 +371,7 @@ describe("photos", function () {
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoJSON;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.hash).to.be.equal(dogHash);
const dbPhoto = await Photo.findOneOrFail({
@@ -402,7 +428,7 @@ describe("photos", function () {
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoJSON;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.name).to.be.equal("Test1");
@@ -429,9 +455,9 @@ describe("photos", function () {
expect(response.body.error).to.be.false;
const photos = response.body.data as IPhotoJSON[];
const photos = response.body.data as IPhotoReqJSON[];
const userPhotos = [seed.dogPhoto.toJSON(), seed.catPhoto.toJSON()];
const userPhotos = [seed.dogPhoto.toReqJSON(), seed.catPhoto.toReqJSON()];
expect(photos).to.deep.equal(userPhotos);
});
@@ -444,9 +470,9 @@ describe("photos", function () {
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoJSON;
const photo = response.body.data as IPhotoReqJSON;
const usedPhoto = seed.catPhoto.toJSON();
const usedPhoto = seed.catPhoto.toReqJSON();
expect(photo).to.deep.equal(usedPhoto);
});