some cleanup, split backend and frontend

This commit is contained in:
2023-07-29 13:04:26 +02:00
parent 14210cf0cf
commit faa0aa62c8
69 changed files with 13194 additions and 12319 deletions

View File

@@ -11,7 +11,7 @@ jobs:
MYSQL_PASSWORD: photos
MYSQL_HOST: "localhost"
working_directory: ~/photos
working_directory: ~/photos/backend
steps:
- checkout
@@ -40,7 +40,7 @@ jobs:
docker:
- image: cimg/node:14.20
working_directory: ~/photos
working_directory: ~/photos/frontend
steps:
- checkout
@@ -50,16 +50,16 @@ jobs:
- run:
name: install frontend deps
command: cd frontend && npm i
command: npm i
- save_cache:
paths:
- frontend/node_modules
- node_modules
key: frontend-dependencies-{{ checksum "frontend/package.json" }}
- run:
name: test frontend
command: cd frontend && npm test
command: npm test
- store_test_results:
path: ~/photos/frontend/frontend-reports/frontend-report.xml
@@ -68,7 +68,7 @@ jobs:
docker:
- image: cimg/node:14.20
working_directory: ~/photos
working_directory: ~/photos/frontend
steps:
- checkout:
@@ -79,16 +79,16 @@ jobs:
- run:
name: install frontend deps
command: cd frontend && npm i
command: npm i
- save_cache:
paths:
- frontend/node_modules
- node_modules
key: frontend-dependencies-{{ checksum "frontend/package.json" }}
- run:
name: build frontend
command: cd frontend && npm run build
command: npm run build
build:
machine:
image: ubuntu-2004:current

View File

@@ -4,3 +4,7 @@ npm-debug.log
frontend/node_modules
frontend/npm-debug.log
frontend/.parcel-cache
backend/node_modules
backend/npm-debug.log
backend/.parcel-cache

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.DS_Store
.idea/
node_modules/
build/
@@ -13,4 +14,4 @@ ormconfig.test.json
data_test
data_dev
data
backend-report.xml
backend-report.xml

View File

@@ -3,7 +3,7 @@
"typeorm"
],
"eslint.workingDirectories": [
".",
"./backend",
"./frontend"
],
"search.exclude": {

View File

@@ -4,8 +4,8 @@ FROM node:16-bullseye as frontbuild
WORKDIR /usr/src/app/frontend
COPY ./frontend/package*.json ./
RUN npm ci --only=production
COPY ./frontend .
COPY ./src/shared ../src/shared
COPY ./frontend/. .
COPY ./shared ../shared
RUN npm run build && bash -O extglob -c 'rm -rfv !("dist")'
WORKDIR ../
RUN bash -O extglob -c 'rm -rfv !("frontend")'
@@ -13,15 +13,17 @@ RUN bash -O extglob -c 'rm -rfv !("frontend")'
FROM node:16-alpine as backexceptwithoutfrontend
WORKDIR /usr/src/app
COPY package*.json ./
COPY ./backend/package*.json ./
RUN npm ci --only=production
COPY ./ ./
RUN rm -rfv frontend
COPY ./backend ./
RUN rm -rfv frontend && unlink src/shared
COPY ./shared src/shared
FROM backexceptwithoutfrontend
WORKDIR /usr/src/app
COPY --from=frontbuild /usr/src/app/frontend ./frontend
COPY ./dockerentry.sh .
#ENV PORT=8080
#ENV TYPEORM_HOST=localhost
@@ -45,6 +47,6 @@ ENV DATA_DIR=data\
#EXPOSE 8080
RUN ["chmod", "+x", "dockerentry.sh"]
RUN ["chmod", "+x", "./dockerentry.sh"]
CMD [ "./dockerentry.sh" ]

17
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
.DS_Store
.idea/
node_modules/
build/
tmp/
temp/
dist/
ormconfig.json
ormconfig.dev.json
ormconfig.test.json
.env
.directory
.history
data_test
data_dev
data
backend-report.xml

12695
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

86
backend/package.json Executable file
View File

@@ -0,0 +1,86 @@
{
"name": "photos",
"version": "0.0.1",
"scripts": {
"start": "ts-node -T -r tsconfig-paths/register src/server.ts",
"ts-node-dev": "ts-node-dev -r tsconfig-paths/register ./src/server.ts",
"test": "cross-env NODE_ENV=test mocha --timeout 15000 -r ts-node/register -r tsconfig-paths/register --reporter mocha-multi-reporters --reporter-options configFile=mocha.json 'src/tests/**/*.ts'",
"lint": "eslint ./src/** --ext .js,.jsx,.ts,.tsx && tsc --noEmit",
"lint-fix": "eslint ./src/** --ext .js,.jsx,.ts,.tsx --fix",
"prettier-check": "prettier src/**/*.ts --check",
"prettify": "prettier src/**/*.ts --write",
"typeorm-dev": "cross-env NODE_ENV=development ts-node -T -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"typeorm": "cross-env NODE_ENV=production ts-node -T -r tsconfig-paths/register ./node_modules/typeorm/cli.js"
},
"license": "MIT",
"dependencies": {
"@koa/cors": "^4.0.0",
"@koa/router": "^12.0.0",
"bcrypt": "^5.1.0",
"class-validator": "^0.14.0",
"exifreader": "^4.13.0",
"hasha": "^5.2.2",
"io-ts": "^2.2.20",
"jsonwebtoken": "^9.0.1",
"koa": "^2.14.2",
"koa-body": "^5.0.0",
"koa-jwt": "^4.0.4",
"koa-logger": "^3.2.1",
"koa-send": "^5.0.1",
"koa-sslify": "^5.0.1",
"koa-static": "^5.0.0",
"mime-types": "^2.1.35",
"mysql": "^2.18.1",
"sharp": "^0.32.4",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typeorm": "^0.2.41",
"typescript": "^5.1.6"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/chai": "^4.3.5",
"@types/concurrently": "^6.4.0",
"@types/deasync": "^0.1.2",
"@types/eslint": "^8.44.1",
"@types/eslint-plugin-prettier": "^3.1.0",
"@types/jsonwebtoken": "^9.0.2",
"@types/koa": "^2.13.7",
"@types/koa__cors": "^4.0.0",
"@types/koa__router": "^12.0.0",
"@types/koa-logger": "^3.1.2",
"@types/koa-send": "^4.1.3",
"@types/koa-sslify": "^4.0.3",
"@types/koa-static": "^4.0.2",
"@types/mime-types": "^2.1.1",
"@types/mocha": "^10.0.1",
"@types/mysql": "^2.15.21",
"@types/prettier": "^2.7.3",
"@types/sharp": "^0.31.1",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0",
"chai": "^4.3.7",
"concurrently": "^8.2.0",
"cross-env": "^7.0.3",
"eslint": "^8.46.0",
"eslint-config-prettier": "^8.9.0",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "^2.28.0",
"eslint-plugin-mocha": "^10.1.0",
"eslint-plugin-prettier": "^5.0.0",
"husky": "^8.0.3",
"mocha": "^10.2.0",
"mocha-junit-reporter": "^2.2.1",
"mocha-multi-reporters": "^1.5.1",
"prettier": "^3.0.0",
"prettier-eslint": "^15.0.1",
"supertest": "^6.3.3",
"ts-node-dev": "^2"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint-all && npm run prettier-check"
}
}
}

View File

@@ -35,6 +35,7 @@ app.use(
if (config.https) {
app.use(sslify({ resolver: xForwardedProtoResolver }));
}
app.use(
jwt({
secret: config.jwtSecret,

View File

@@ -1,11 +1,10 @@
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 { IPhotoReqJSON, IPhotoJSON } from "~/shared/types";
import {
AfterRemove,
BaseEntity,
BeforeInsert,
BeforeRemove,
@@ -18,37 +17,18 @@ import {
} from "typeorm";
import { User } from "./User";
import {
isAlphanumeric,
IsAlphanumeric,
IsHash,
IsIn,
IsMimeType,
isNumber,
Length,
Matches,
validateOrReject,
} from "class-validator";
import { config } from "~config";
import { fileCheck, getShotDate, resizeToJpeg } from "~util";
export interface IPhotoJSON {
id: number;
user: number;
hash: string;
size: string;
format: string;
createdAt: number;
editedAt: number;
shotAt: number;
uploaded: boolean;
}
export interface IPhotoReqJSON extends IPhotoJSON {
accessToken: string;
}
export const thumbSizes = ["512", "1024", "2048", "original"];
export type ThumbSize = typeof thumbSizes[number];
export type ThumbSize = (typeof thumbSizes)[number];
@Entity()
@Index(["hash", "size", "user"], { unique: true })

View File

@@ -2,9 +2,10 @@ import * as bcrypt from "bcrypt";
import * as jwt from "jsonwebtoken";
import * as path from "path";
import * as fs from "fs/promises";
import { IUserJSON, IUserAuthJSON } from "~/shared/types";
import {
AfterInsert,
AfterRemove,
BaseEntity,
BeforeInsert,
BeforeRemove,
@@ -17,25 +18,7 @@ import {
} from "typeorm";
import { config } from "../config";
import { Photo } from "./Photo";
import {
IsAlphanumeric,
IsBase32,
IsBase64,
IsEmail,
IsHash,
validateOrReject,
} from "class-validator";
export type IUserJSON = Pick<User, "id" | "username" | "isAdmin">;
export interface IUserJWT extends IUserJSON {
ext: number;
iat: number;
}
export interface IUserAuthJSON extends IUserJSON {
jwt: string;
}
import { IsAlphanumeric, IsEmail, validateOrReject } from "class-validator";
@Entity()
export class User extends BaseEntity {

View File

@@ -1,8 +1,16 @@
import * as Router from "@koa/router";
import { IPhotoReqJSON, Photo } from "~entity/Photo";
import { User } from "~entity/User";
import { IAPIResponse, IPhotosListPagination } from "~/shared/types";
import * as fs from "fs/promises";
import { Photo } from "~entity/Photo";
import {
IPhotoReqJSON,
IPhotosNewRespBody,
IPhotosNewPostBody,
IPhotoByIDDeleteRespBody,
IPhotosUploadRespBody,
IPhotosListRespBody,
IPhotosByIDGetRespBody,
IPhotosDeleteRespBody,
IPhotosDeleteBody,
IAPIResponse, IPhotosListPagination } from "~/shared/types";
import send = require("koa-send");
import { getHash, getSize } from "~util";
import * as jwt from "jsonwebtoken";
@@ -12,12 +20,6 @@ import { In } from "typeorm";
export const photosRouter = new Router();
export interface IPhotosNewPostBody {
hash: string | undefined;
size: string | undefined;
format: string | undefined;
}
export type IPhotosNewRespBody = IAPIResponse<IPhotoReqJSON>;
photosRouter.post("/photos/new", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
@@ -67,7 +69,6 @@ photosRouter.post("/photos/new", async (ctx) => {
} as IPhotosNewRespBody;
});
export type IPhotosUploadRespBody = IAPIResponse<IPhotoReqJSON>;
photosRouter.post("/photos/upload/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
@@ -175,7 +176,6 @@ photosRouter.patch("/photos/byID/:id", async (ctx) => {
});
*/
export type IPhotosListRespBody = IAPIResponse<IPhotoReqJSON[]>;
photosRouter.get("/photos/list", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
@@ -217,7 +217,6 @@ photosRouter.get("/photos/list", async (ctx) => {
} as IPhotosListRespBody;
});
export type IPhotosByIDGetRespBody = IAPIResponse<IPhotoReqJSON>;
photosRouter.get("/photos/byID/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
@@ -259,7 +258,7 @@ photosRouter.get("/photos/showByID/:id/:token", async (ctx) => {
}
try {
jwt.verify(token, config.jwtSecret) as IPhotoReqJSON;
jwt.verify(token, config.jwtSecret);
} catch (e) {
ctx.throw(401);
}
@@ -353,7 +352,6 @@ photosRouter.get("/photos/getShowByIDToken/:id", async (ctx) => {
ctx.body = { error: false, data: token } as IPhotosGetShowTokenByID;
});
export type IPhotoByIDDeleteRespBody = IAPIResponse<boolean>;
photosRouter.delete("/photos/byID/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
@@ -385,11 +383,6 @@ photosRouter.delete("/photos/byID/:id", async (ctx) => {
} as IPhotoByIDDeleteRespBody;
});
export interface IPhotosDeleteBody {
photos: IPhotoReqJSON[];
}
export type IPhotosDeleteRespBody = IAPIResponse<boolean>;
photosRouter.post("/photos/delete", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);

View File

@@ -1,11 +1,19 @@
import * as Router from "@koa/router";
import { getConfigValue, ConfigKey } from "~entity/Config";
import { IUserAuthJSON, IUserJWT, User } from "~entity/User";
import { IAPIResponse } from "~/shared/types";
import { User } from "~entity/User";
import {
IUserJWT,
IUserGetRespBody,
IUserEditRespBody,
IUserSignupBody,
IUserSignupRespBody,
IUserLoginRespBody,
IUserEditBody,
IUserLoginBody,
} from "~/shared/types";
export const userRouter = new Router();
export type IUserGetRespBody = IAPIResponse<IUserAuthJSON>;
userRouter.get("/users/user", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
@@ -23,11 +31,6 @@ userRouter.get("/users/user", async (ctx) => {
ctx.body = { error: false, data: user.toAuthJSON() } as IUserGetRespBody;
});
export interface IUserLoginBody {
username: string | undefined;
password: string | undefined;
}
export type IUserLoginRespBody = IAPIResponse<IUserAuthJSON>;
userRouter.post("/users/login", async (ctx) => {
const request = ctx.request;
@@ -50,12 +53,6 @@ userRouter.post("/users/login", async (ctx) => {
ctx.body = { error: false, data: user.toAuthJSON() } as IUserLoginRespBody;
});
export interface IUserSignupBody {
username: string | undefined;
password: string | undefined;
email: string | undefined;
}
export type IUserSignupRespBody = IAPIResponse<IUserAuthJSON>;
userRouter.post("/users/signup", async (ctx) => {
const request = ctx.request;
@@ -97,10 +94,6 @@ userRouter.post("/users/signup", async (ctx) => {
ctx.body = { error: false, data: user.toAuthJSON() } as IUserSignupRespBody;
});
export interface IUserEditBody {
password: string | undefined;
}
export type IUserEditRespBody = IAPIResponse<IUserAuthJSON>;
userRouter.post("/users/edit", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);

View File

@@ -1,8 +1,8 @@
import { Connection } from "typeorm";
import { Config, ConfigKey, setConfigValue } from "~entity/Config";
import { app } from "./app";
import { app } from "~app";
import { config } from "./config";
import { connect } from "./config/database";
import { connect } from "~config/database";
async function readConfig() {
if (process.env.SIGNUP_ALLOWED) {

1
backend/src/shared Symbolic link
View File

@@ -0,0 +1 @@
../../shared

View File

@@ -3,12 +3,12 @@ import { connect } from "config/database";
import * as request from "supertest";
import { getConnection } from "typeorm";
import { app } from "~app";
import { Photo, IPhotoReqJSON } from "~entity/Photo";
import {
import { Photo } from "~entity/Photo";
import { IPhotoReqJSON ,
IPhotosDeleteBody,
IPhotosListRespBody,
IPhotosNewPostBody,
} from "~routes/photos";
} from "~shared/types";
import * as fs from "fs/promises";
import { constants as fsConstants } from "fs";
import * as jwt from "jsonwebtoken";
@@ -30,7 +30,6 @@ import {
prepareMetadata,
seedDB,
} from "./util";
import { sleep } from "deasync";
import { config } from "~config";
const callback = app.callback();
@@ -121,7 +120,7 @@ describe("photos", function () {
})
.expect(200);
const dogSmallThumbSize = (
await fs.stat(await seed.dogPhoto.getThumbPath("512"))
await fs.stat(seed.dogPhoto.getThumbPath("512"))
).size;
expect(parseInt(response.header["content-length"])).to.equal(
dogSmallThumbSize,
@@ -160,7 +159,7 @@ describe("photos", function () {
})
.expect(200);
const dogSmallThumbSize = (
await fs.stat(await seed.dogPhoto.getThumbPath("512"))
await fs.stat(seed.dogPhoto.getThumbPath("512"))
).size;
expect(parseInt(response.header["content-length"])).to.equal(
dogSmallThumbSize,
@@ -174,7 +173,7 @@ describe("photos", function () {
})
.expect(200);
const dogSmallThumbSize2 = (
await fs.stat(await seed.dogPhoto.getThumbPath("512"))
await fs.stat(seed.dogPhoto.getThumbPath("512"))
).size;
expect(parseInt(response.header["content-length"])).to.equal(
dogSmallThumbSize2,

View File

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@@ -3,7 +3,7 @@ import { connect } from "config/database";
import * as request from "supertest";
import { getConnection } from "typeorm";
import { app } from "~app";
import { IUserAuthJSON, User } from "~entity/User";
import { User } from "~entity/User";
import {
IUserEditBody,
IUserEditRespBody,
@@ -12,7 +12,7 @@ import {
IUserLoginRespBody,
IUserSignupBody,
IUserSignupRespBody,
} from "~routes/users";
} from "~shared/types";
import { allowSignups, ISeed, seedDB } from "./util";

View File

@@ -4,7 +4,6 @@ import { User } from "entity/User";
import { Photo } from "~entity/Photo";
import { getHash, getSize } from "~util";
import { Config, ConfigKey, setConfigValue } from "~entity/Config";
import { config } from "chai";
export const dogPath = "./src/tests/integration/photos/dog.jpg";
export const catPath = "./src/tests/integration/photos/cat.jpg";

1
dockercomposeexample/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dbdata

View File

@@ -1,7 +1,8 @@
version: "3.8"
services:
photosapp:
image: stepanusatiuk/photos:main
# image: stepanusatiuk/photos:main
build: ../
restart: always
ports:
- "8080:8080"
@@ -21,4 +22,3 @@ services:
- ./dbdata:/var/lib/mysql
env_file:
- db.env

5
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"trailingComma": "all",
"tabWidth": 4,
"endOfLine": "auto"
}

View File

@@ -1,7 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("./tsconfig");
module.exports = {
preset: "ts-jest",
moduleNameMapper: {
@@ -11,6 +8,7 @@ module.exports = {
"react-spring/renderprops":
"<rootDir>/node_modules/react-spring/renderprops.cjs",
"react-spring": "<rootDir>/node_modules/react-spring/web.cjs",
"~(.*)": "<rootDir>/$1",
},
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
testEnvironment: "jsdom",

View File

@@ -26,6 +26,7 @@
"eslint-plugin-react": "^7.33.0",
"eslint-plugin-react-hooks": "^4.6.0",
"flush-promises": "^1.0.2",
"io-ts": "^2.2.20",
"jest": "^29.6.2",
"jest-environment-jsdom": "^29.6.2",
"jest-junit": "^14.0.1",
@@ -60,7 +61,8 @@
"@types/react-redux": "^7.1.25",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"@types/spark-md5": "^3.0.2"
"@types/spark-md5": "^3.0.2",
"parcel-resolver-ts-base-url": "^1.3.1"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -6353,6 +6355,12 @@
"node": ">= 6"
}
},
"node_modules/fp-ts": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.1.tgz",
"integrity": "sha512-by7U5W8dkIzcvDofUcO42yl9JbnHTEDBrzu3pt5fKT+Z4Oy85I21K80EYJYdjQGC2qum4Vo55Ag57iiIK4FYuA==",
"peer": true
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -6937,6 +6945,14 @@
"node": ">= 0.4"
}
},
"node_modules/io-ts": {
"version": "2.2.20",
"resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.20.tgz",
"integrity": "sha512-Rq2BsYmtwS5vVttie4rqrOCIfHCS9TgpRLFpKQCM1wZBBRY9nWVGmEvm2FnDbSE2un1UE39DvFpTR5UL47YDcA==",
"peerDependencies": {
"fp-ts": "^2.5.0"
}
},
"node_modules/is-arguments": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
@@ -8063,6 +8079,12 @@
"node": ">=6"
}
},
"node_modules/jsonc-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==",
"dev": true
},
"node_modules/jsx-ast-utils": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz",
@@ -9031,6 +9053,23 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/parcel-resolver-ts-base-url": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/parcel-resolver-ts-base-url/-/parcel-resolver-ts-base-url-1.3.1.tgz",
"integrity": "sha512-/GfSk1P6oW7uXtViKlqGtH406kQ17Cu+bKHVTdbuIh8t8hICsJrYRqeDDx1dJWsiMAKvtXzXT0TBWLkdusS8PQ==",
"dev": true,
"dependencies": {
"jsonc-parser": "^3.2.0"
},
"engines": {
"node": ">=12",
"parcel": "^2"
},
"peerDependencies": {
"@parcel/plugin": "^2.0.0",
"parcel": "^2.0.0"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -15762,6 +15801,12 @@
"mime-types": "^2.1.12"
}
},
"fp-ts": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.1.tgz",
"integrity": "sha512-by7U5W8dkIzcvDofUcO42yl9JbnHTEDBrzu3pt5fKT+Z4Oy85I21K80EYJYdjQGC2qum4Vo55Ag57iiIK4FYuA==",
"peer": true
},
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -16150,6 +16195,12 @@
"side-channel": "^1.0.4"
}
},
"io-ts": {
"version": "2.2.20",
"resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.20.tgz",
"integrity": "sha512-Rq2BsYmtwS5vVttie4rqrOCIfHCS9TgpRLFpKQCM1wZBBRY9nWVGmEvm2FnDbSE2un1UE39DvFpTR5UL47YDcA==",
"requires": {}
},
"is-arguments": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
@@ -16949,6 +17000,12 @@
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="
},
"jsonc-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==",
"dev": true
},
"jsx-ast-utils": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz",
@@ -17613,6 +17670,15 @@
"get-port": "^4.2.0"
}
},
"parcel-resolver-ts-base-url": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/parcel-resolver-ts-base-url/-/parcel-resolver-ts-base-url-1.3.1.tgz",
"integrity": "sha512-/GfSk1P6oW7uXtViKlqGtH406kQ17Cu+bKHVTdbuIh8t8hICsJrYRqeDDx1dJWsiMAKvtXzXT0TBWLkdusS8PQ==",
"dev": true,
"requires": {
"jsonc-parser": "^3.2.0"
}
},
"parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",

View File

@@ -3,9 +3,11 @@
"scripts": {
"start": "parcel src/index.html",
"build": "parcel build src/index.html",
"test": "jest",
"lint": "eslint ./src/** --ext .js,.jsx,.ts,.tsx",
"lint-fix": "eslint ./src/** --ext .js,.jsx,.ts,.tsx --fix",
"test": "jest"
"prettier-check": "prettier src/**/*.ts src/**/*.tsx --check",
"prettify": "prettier src/**/*.ts src/**/*.tsx --write"
},
"dependencies": {
"@blueprintjs/core": "^4.14.1",
@@ -28,6 +30,7 @@
"eslint-plugin-react": "^7.33.0",
"eslint-plugin-react-hooks": "^4.6.0",
"flush-promises": "^1.0.2",
"io-ts": "^2.2.20",
"jest": "^29.6.2",
"jest-environment-jsdom": "^29.6.2",
"jest-junit": "^14.0.1",

View File

@@ -1,6 +1,5 @@
import { Position, Toaster } from "@blueprintjs/core";
import { isNumber } from "class-validator";
import { IPhotoReqJSON } from "../../src/entity/Photo";
import { IPhotoReqJSON } from "./shared/types";
export const AppToaster = Toaster.create({
className: "recipe-toaster",

View File

@@ -2,29 +2,24 @@ import "./Home.scss";
import {
Alignment,
Breadcrumbs,
Button,
Classes,
IBreadcrumbProps,
Icon,
Menu,
MenuItem,
Navbar,
Popover,
Spinner,
} from "@blueprintjs/core";
import * as React from "react";
import { connect } from "react-redux";
import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
import { animated, config, Transition } from "react-spring/renderprops";
import { Dispatch } from "redux";
import { IUserJSON } from "../../../src/entity/User";
import { IUserJSON } from "../shared/types";
import { Account } from "../Account/Account";
import { Overview } from "../Photos/Overview";
import { toggleDarkMode } from "../redux/localSettings/actions";
import { IAppState } from "../redux/reducers";
import { logoutUser } from "../redux/user/actions";
import { Photo } from "../Photos/Photo";
import { PhotoRoute } from "../Photos/PhotoRoute";
import { UploadStatus } from "./UploadStatus";
@@ -95,28 +90,24 @@ export class HomeComponent extends React.PureComponent<IHomeProps> {
transform: "translate3d(400px,0,0)",
}}
>
{(_location: any) => (style: any) =>
(
<animated.div
style={style}
className="viewComponent"
>
<Switch location={_location}>
<Route
path="/account"
component={Account}
/>
<Route
path="/photos/:id"
component={PhotoRoute}
/>
<Route
path="/"
component={Overview}
/>
</Switch>
</animated.div>
)}
{(_location: any) => (style: any) => (
<animated.div
style={style}
className="viewComponent"
>
<Switch location={_location}>
<Route
path="/account"
component={Account}
/>
<Route
path="/photos/:id"
component={PhotoRoute}
/>
<Route path="/" component={Overview} />
</Switch>
</animated.div>
)}
</Transition>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Button, Icon, Popover, Spinner } from "@blueprintjs/core";
import { Button } from "@blueprintjs/core";
import * as React from "react";
import { connect } from "react-redux";
import { IAppState } from "../redux/reducers";
@@ -12,9 +12,9 @@ export interface IUploadStatusComponentProps {
uploadingQueue: number;
}
export const UploadStatusComponent: React.FunctionComponent<IUploadStatusComponentProps> = (
props,
) => {
export const UploadStatusComponent: React.FunctionComponent<
IUploadStatusComponentProps
> = (props) => {
const { creatingNow, creatingQueue, uploadingNow, uploadingQueue } = props;
const uploading =
creatingNow > 0 ||

View File

@@ -3,20 +3,18 @@ import "./Overview.scss";
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { IAppState } from "../redux/reducers";
import { IAppState } from "~/src/redux/reducers";
import {
photosDeleteCancel,
photosDeleteStart,
photosLoadStart,
} from "../redux/photos/actions";
import { IPhotoReqJSON } from "../../../src/entity/Photo";
import { LoadingStub } from "../LoadingStub";
import { IPhotoReqJSON } from "~/src/shared/types";
import { PhotoCard } from "./PhotoCard";
import {
Alignment,
Button,
Classes,
H1,
H2,
H3,
Navbar,
@@ -25,8 +23,7 @@ import {
} from "@blueprintjs/core";
import { UploadButton } from "./UploadButton";
import { Photo } from "./Photo";
import { getPhotoThumbPath } from "../redux/api/photos";
import { showDeletionToast } from "../AppToaster";
import { showDeletionToast } from "~/src/AppToaster";
export interface IOverviewComponentProps {
photos: IPhotoReqJSON[];

View File

@@ -2,13 +2,9 @@ import "./Photo.scss";
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { IPhotoReqJSON } from "../../../src/entity/Photo";
import { IPhotoReqJSON } from "~/src/shared/types";
import { LoadingStub } from "../LoadingStub";
import {
fetchPhoto,
getPhotoImgPath,
getPhotoThumbPath,
} from "../redux/api/photos";
import { getPhotoImgPath, getPhotoThumbPath } from "../redux/api/photos";
import { photoLoadStart } from "../redux/photos/actions";
import { IPhotoState } from "../redux/photos/reducer";
import { IAppState } from "../redux/reducers";

View File

@@ -8,15 +8,14 @@ import {
Spinner,
} from "@blueprintjs/core";
import * as React from "react";
import { IPhotoReqJSON } from "../../../src/entity/Photo";
import { getPhotoImgPath, getPhotoThumbPath } from "../redux/api/photos";
import { IPhotoReqJSON } from "~/src/shared/types";
import { getPhotoThumbPath } from "../redux/api/photos";
import { showDeletionToast } from "../AppToaster";
import { Dispatch } from "redux";
import { photosDeleteCancel, photosDeleteStart } from "../redux/photos/actions";
import { connect } from "react-redux";
import { LoadingStub } from "../LoadingStub";
import { RouteComponentProps, withRouter } from "react-router";
import { LargeSize, PreviewSize } from "./helper";
import { PreviewSize } from "./helper";
export interface IPhotoCardComponentProps extends RouteComponentProps {
photo: IPhotoReqJSON;

View File

@@ -1,7 +1,4 @@
import {
IUserLoginRespBody,
IUserSignupRespBody,
} from "../../../../../src/routes/users";
import { IUserLoginRespBody, IUserSignupRespBody } from "~/src/shared/types";
import { fetchJSON } from "../utils";
export async function login(

View File

@@ -1,12 +1,12 @@
import { IPhotoReqJSON } from "../../../../src/entity/Photo";
import { IPhotoReqJSON } from "~/src/shared/types";
import {
IPhotosByIDGetRespBody,
IPhotosDeleteRespBody,
IPhotosListRespBody,
IPhotosNewRespBody,
IPhotosUploadRespBody,
} from "../../../../src/routes/photos";
import { apiRoot } from "../../env";
} from "~/src/shared/types";
import { apiRoot } from "~src/env";
import { fetchJSONAuth } from "./utils";
export function getPhotoImgPath(photo: IPhotoReqJSON): string {

View File

@@ -1,10 +1,11 @@
import { fetchJSONAuth } from "../utils";
import { IUserEditRespBody, IUserGetRespBody } from "../../../../../src/routes/users";
import { IUserEditRespBody, IUserGetRespBody } from "~/src/shared/types";
export async function fetchUser(): Promise<IUserGetRespBody> {
return (fetchJSONAuth("/users/user", "GET") as unknown) as Promise<
IUserGetRespBody
>;
return fetchJSONAuth(
"/users/user",
"GET",
) as unknown as Promise<IUserGetRespBody>;
}
export async function changeUserPassword(

View File

@@ -1,4 +1,5 @@
import { apiRoot } from "../../env";
import { apiRoot } from "~src/env";
import { IAPIResponse } from "~/src/shared/types";
let token: string | null;
@@ -14,12 +15,12 @@ export function deleteToken(): void {
token = null;
}
export async function fetchJSON(
export async function fetchJSON<T>(
path: string,
method: string,
body?: string | Record<string, unknown> | File,
headers?: Record<string, string>,
): Promise<Record<string, unknown>> {
): Promise<IAPIResponse<T>> {
if (typeof body === "object" && !(body instanceof File)) {
body = JSON.stringify(body);
headers = {
@@ -27,7 +28,7 @@ export async function fetchJSON(
"Content-Type": "application/json",
};
}
// TODO: io-ts or something like that
if (body instanceof File) {
const formData = new FormData();
formData.append("photo", body);
@@ -37,7 +38,7 @@ export async function fetchJSON(
body: formData,
});
const json = (await response.json()) as Record<string, unknown>;
return json;
return json as unknown as IAPIResponse<T>;
}
const response = await fetch(apiRoot + path, {
@@ -46,15 +47,15 @@ export async function fetchJSON(
headers,
});
const json = (await response.json()) as Record<string, unknown>;
return json;
return json as unknown as IAPIResponse<T>;
}
export async function fetchJSONAuth(
export async function fetchJSONAuth<T>(
path: string,
method: string,
body?: string | Record<string, unknown> | File,
headers?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
): Promise<IAPIResponse<T>> {
if (token) {
return fetchJSON(path, method, body, {
...headers,

View File

@@ -1,5 +1,5 @@
import { Action } from "redux";
import { IUserAuthJSON } from "../../../../src/entity/User";
import { IUserAuthJSON } from "~/src/shared/types";
export enum AuthTypes {
AUTH_START = "AUTH_START",

View File

@@ -1,7 +1,7 @@
import { Reducer } from "react";
import { setToken } from "../../redux/api/utils";
import { UserAction, UserTypes } from "../../redux/user/actions";
import { setToken } from "~src/redux/api/utils";
import { UserAction, UserTypes } from "~src/redux/user/actions";
import { AuthAction, AuthTypes } from "./actions";
export interface IAuthState {

View File

@@ -8,7 +8,7 @@ import {
race,
takeLatest,
} from "redux-saga/effects";
import { login, signup } from "../../redux/api/auth";
import { login, signup } from "~src/redux/api/auth";
import {
authFail,

View File

@@ -1,5 +1,5 @@
import { Reducer } from "react";
import { UserAction, UserTypes } from "../../redux/user/actions";
import { UserAction, UserTypes } from "~src/redux/user/actions";
import { LocalSettingsAction, LocalSettingsTypes } from "./actions";

View File

@@ -1,10 +1,10 @@
import { Action } from "redux";
import { IPhotoReqJSON, Photo } from "../../../../src/entity/Photo";
import { IPhotoReqJSON } from "~src/shared/types";
import {
showPhotoCreateFailToast,
showPhotoUploadFileFailToast,
showPhotoUploadJSONFailToast,
} from "../../AppToaster";
} from "~src/AppToaster";
export enum PhotoTypes {
PHOTOS_LOAD_START = "PHOTOS_LOAD",

View File

@@ -1,6 +1,6 @@
import { Reducer } from "redux";
import { IPhotoJSON, IPhotoReqJSON } from "../../../../src/entity/Photo";
import { UserAction, UserTypes } from "../../redux/user/actions";
import { IPhotoReqJSON } from "~/src/shared/types";
import { UserAction, UserTypes } from "~src/redux/user/actions";
import { PhotoAction, PhotoTypes } from "./actions";
export interface IPhotoState {

View File

@@ -1,9 +1,7 @@
import {
all,
call,
cancel,
delay,
fork,
put,
race,
takeLatest,
@@ -18,7 +16,7 @@ import {
fetchPhoto,
fetchPhotosList,
uploadPhoto,
} from "../../redux/api/photos";
} from "~src/redux/api/photos";
import {
IPhotosDeleteStartAction,
IPhotoLoadStartAction,
@@ -35,14 +33,12 @@ import {
photosLoadSuccess,
photosStartFetchingSpinner,
PhotoTypes,
photoUploadFail,
photoUploadFailWithFile,
photoUploadQueue,
photoUploadStart,
photoUploadSuccess,
} from "./actions";
import { IPhotosNewRespBody } from "../../../../src/routes/photos";
import { IPhotosListPagination } from "../../../../src/shared/types";
import { IPhotosNewRespBody, IPhotosListPagination } from "~src/shared/types";
// Thanks, https://dev.to/qortex/compute-md5-checksum-for-a-file-in-typescript-59a4
function computeChecksumMd5(file: File): Promise<string> {

View File

@@ -1,6 +1,9 @@
import { Action } from "redux";
import { IUserAuthJSON, IUserJSON } from "../../../../src/entity/User";
import { showPasswordNotSavedToast, showPasswordSavedToast } from "../../AppToaster";
import { IUserAuthJSON } from "~src/shared/types";
import {
showPasswordNotSavedToast,
showPasswordSavedToast,
} from "~src/AppToaster";
export enum UserTypes {
USER_GET = "USER_GET",

View File

@@ -1,6 +1,6 @@
import { Reducer } from "react";
import { IUserJSON } from "../../../../src/entity/User";
import { AuthAction, AuthTypes } from "../../redux/auth/actions";
import { IUserJSON } from "~/src/entity/User";
import { AuthAction, AuthTypes } from "~src/redux/auth/actions";
import { UserAction, UserTypes } from "./actions";
export interface IUserState {

View File

@@ -1,5 +1,5 @@
import { all, call, delay, put, race, takeLatest } from "redux-saga/effects";
import { changeUserPassword, fetchUser } from "../../redux/api/user";
import { changeUserPassword, fetchUser } from "~src/redux/api/user";
import {
getUserFail,

1
frontend/src/shared Symbolic link
View File

@@ -0,0 +1 @@
../../shared

View File

@@ -15,7 +15,13 @@
"strictNullChecks": true,
"skipLibCheck": true,
"isolatedModules": true,
"downlevelIteration": true
"downlevelIteration": true,
"baseUrl": "./",
"paths": {
"~*": [
"./*"
]
}
},
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./jest.config.js"]
}

12097
package-lock.json generated

File diff suppressed because it is too large Load Diff

102
package.json Executable file → Normal file
View File

@@ -1,93 +1,27 @@
{
"name": "photos",
"version": "0.0.1",
"name": "photos-root",
"scripts": {
"start-frontend": "cd frontend && npm start",
"start": "ts-node -T -r tsconfig-paths/register src/server.ts",
"ts-node-dev": "ts-node-dev -r tsconfig-paths/register ./src/server.ts",
"dev": "cross-env NODE_ENV=development concurrently npm:ts-node-dev npm:start-frontend -c 'blue,green'",
"test": "cross-env NODE_ENV=test mocha --timeout 15000 -r ts-node/register -r tsconfig-paths/register --reporter mocha-multi-reporters --reporter-options configFile=mocha.json 'src/tests/**/*.ts' ",
"test-frontend": "cd frontend && npm test",
"test-all": "npm test && npm run test-frontend",
"lint": "eslint ./src/** --ext .js,.jsx,.ts,.tsx && tsc --noEmit",
"lint-fix": "eslint ./src/** --ext .js,.jsx,.ts,.tsx --fix",
"dev-backend": "cd backend && npm run ts-node-dev",
"dev-frontend": "cd frontend && npm run start",
"dev-all": "cross-env NODE_ENV=development concurrently npm:dev-backend npm:dev-frontend -c 'blue,green'",
"test-backend": "cd backend && npm run test",
"test-frontend": "cd frontend && npm run test",
"test-all": "npm run test-backend && npm run test-frontend",
"lint-backend": "cd backend && npm run lint",
"lint-backend-fix": "cd backend && npm run lint-fix",
"lint-frontend": "cd frontend && npm run lint",
"lint-frontend-fix": "cd frontend && npm run lint-fix",
"lint-all": "npm run lint && npm run lint-frontend",
"lint-all-fix": "npm run lint-fix && npm run lint-frontend-fix",
"prettier-check": "prettier src/**/*.ts frontend/src/**/*.ts frontend/src/**/*.tsx --check",
"prettify": "prettier src/**/*.ts frontend/src/**/*.ts frontend/src/**/*.tsx --write",
"typeorm-dev": "cross-env NODE_ENV=development ts-node -T -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"typeorm": "cross-env NODE_ENV=production ts-node -T -r tsconfig-paths/register ./node_modules/typeorm/cli.js"
},
"license": "MIT",
"dependencies": {
"@koa/cors": "^4.0.0",
"@koa/router": "^12.0.0",
"bcrypt": "^5.1.0",
"class-validator": "^0.14.0",
"exifreader": "^4.13.0",
"hasha": "^5.2.2",
"jsonwebtoken": "^9.0.1",
"koa": "^2.14.2",
"koa-body": "^5.0.0",
"koa-jwt": "^4.0.4",
"koa-logger": "^3.2.1",
"koa-send": "^5.0.1",
"koa-sslify": "^5.0.1",
"koa-static": "^5.0.0",
"mime-types": "^2.1.35",
"mysql": "^2.18.1",
"sharp": "^0.32.4",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typeorm": "^0.2.41",
"typescript": "^5.1.6"
"lint-all": "npm run lint-backend && npm run lint-frontend",
"lint-all-fix": "npm run lint-backend-fix && npm run lint-frontend-fix",
"prettier-check-backend": "cd backend && npm run prettier-check",
"prettify-backend": "cd backend && npm run prettify",
"prettier-check-frontend": "cd backend && npm run prettier-check",
"prettify-frontend": "cd backend && npm run prettify",
"prettier-check-all": "npm run prettier-check-backend && npm run prettier-check-frontend",
"prettify-all": "npm run prettify-backend && npm run prettify-frontend"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/chai": "^4.3.5",
"@types/concurrently": "^6.4.0",
"@types/deasync": "^0.1.2",
"@types/eslint": "^8.44.1",
"@types/eslint-plugin-prettier": "^3.1.0",
"@types/jsonwebtoken": "^9.0.2",
"@types/koa": "^2.13.7",
"@types/koa__cors": "^4.0.0",
"@types/koa__router": "^12.0.0",
"@types/koa-logger": "^3.1.2",
"@types/koa-send": "^4.1.3",
"@types/koa-sslify": "^4.0.3",
"@types/koa-static": "^4.0.2",
"@types/mime-types": "^2.1.1",
"@types/mocha": "^10.0.1",
"@types/mysql": "^2.15.21",
"@types/prettier": "^2.7.3",
"@types/sharp": "^0.31.1",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0",
"chai": "^4.3.7",
"concurrently": "^8.2.0",
"cross-env": "^7.0.3",
"eslint": "^8.46.0",
"eslint-config-prettier": "^8.9.0",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "^2.28.0",
"eslint-plugin-mocha": "^10.1.0",
"eslint-plugin-prettier": "^5.0.0",
"husky": "^8.0.3",
"mocha": "^10.2.0",
"mocha-junit-reporter": "^2.2.1",
"mocha-multi-reporters": "^1.5.1",
"prettier": "^3.0.0",
"prettier-eslint": "^15.0.1",
"supertest": "^6.3.3",
"ts-node-dev": "^2"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint-all && npm run prettier-check"
}
"cross-env": "^7.0.3"
}
}

74
shared/types.ts Normal file
View File

@@ -0,0 +1,74 @@
interface IAPIErrorResponse {
data: null;
error: string;
}
interface IAPISuccessResponse<T> {
error: false;
data: T;
}
export type IAPIResponse<T> = IAPIErrorResponse | IAPISuccessResponse<T>;
export interface IPhotoJSON {
id: number;
user: number;
hash: string;
size: string;
format: string;
createdAt: number;
editedAt: number;
shotAt: number;
uploaded: boolean;
}
export interface IPhotoReqJSON extends IPhotoJSON {
accessToken: string;
}
export interface IUserJSON {
id: number;
username: string;
isAdmin: boolean;
}
export interface IUserJWT extends IUserJSON {
ext: number;
iat: number;
}
export interface IUserAuthJSON extends IUserJSON {
jwt: string;
}
export interface IPhotosNewPostBody {
hash: string | undefined;
size: string | undefined;
format: string | undefined;
}
export type IPhotosNewRespBody = IAPIResponse<IPhotoReqJSON>;
export type IPhotosUploadRespBody = IAPIResponse<IPhotoReqJSON>;
export type IPhotosListRespBody = IAPIResponse<IPhotoReqJSON[]>;
export type IPhotosByIDGetRespBody = IAPIResponse<IPhotoReqJSON>;
export type IPhotoByIDDeleteRespBody = IAPIResponse<boolean>;
export type IPhotosDeleteRespBody = IAPIResponse<boolean>;
export type IUserGetRespBody = IAPIResponse<IUserAuthJSON>;
export type IUserLoginRespBody = IAPIResponse<IUserAuthJSON>;
export interface IUserSignupBody {
username: string | undefined;
password: string | undefined;
email: string | undefined;
}
export type IUserSignupRespBody = IAPIResponse<IUserAuthJSON>;
export interface IUserEditBody {
password: string | undefined;
}
export type IUserEditRespBody = IAPIResponse<IUserAuthJSON>;
export interface IUserLoginBody {
username: string | undefined;
password: string | undefined;
}
export interface IPhotosDeleteBody {
photos: IPhotoReqJSON[];
}
export const IPhotosListPagination = 50;

View File

@@ -1,14 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface IAPIErrorResponse<T> {
data: null;
error: string;
}
interface IAPISuccessResponse<T> {
error: false;
data: T;
}
export type IAPIResponse<T> = IAPIErrorResponse<T> | IAPISuccessResponse<T>;
export const IPhotosListPagination = 50;