Initial commit

This commit is contained in:
Vu Khanh Du 2024-01-31 11:08:20 +07:00
parent cdce8df227
commit 40578d8b6a
186 changed files with 12163 additions and 6624 deletions

1
cope2n-fe/.dockerignore Normal file
View File

@ -0,0 +1 @@
/node_modules

View File

@ -0,0 +1,3 @@
VITE_PORT=8080
VITE_PROXY=https://107.120.133.22/
VITE_KUBEFLOW_HOST=https://107.120.133.22:8085

14
cope2n-fe/.eslintrc Normal file
View File

@ -0,0 +1,14 @@
{
"env": {
"browser": true,
"es6": true
},
"extends": [
"react-app",
"prettier",
"plugin:@tanstack/eslint-plugin-query/recommended"
],
"ignorePatterns": [
"**/components/react-via/**/*.js"
]
}

44
cope2n-fe/.gitignore vendored
View File

@ -1,27 +1,33 @@
# Logs # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
logs
*.log # dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
/dist
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# rollup-plugin-visualizer
stats.html
# Ignore all the installed packages
node_modules node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# linguijs locales # linguijs locales
src/locales/**/*.ts src/locales/**/*.ts

View File

@ -1,20 +0,0 @@
stages:
- check
sonarqube-check:
stage: check
image:
name: sonarsource/sonar-scanner-cli:latest
entrypoint: ['']
variables:
SONAR_USER_HOME: '${CI_PROJECT_DIR}/.sonar' # Defines the location of the analysis task cache
GIT_DEPTH: '0' # Tells git to fetch all the branches of the project, required by the analysis task
# cache:
# key: '${CI_JOB_NAME}'
# paths:
# - .sonar/cache
script:
- sonar-scanner
allow_failure: true
only:
- develop

View File

@ -1,4 +1,11 @@
.*/
./dist/
./data/
3rdparty/
node_modules/ node_modules/
dist/ keys/
logs/ logs/
static/ static/
templates/
src/components/react-via/js/
src/components/react-via/styles/

View File

@ -1,9 +1,29 @@
{ {
"arrowParens": "always",
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": true,
"printWidth": 80, "printWidth": 80,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true, "semi": true,
"singleQuote": true, "singleQuote": true,
"tabWidth": 2, "tabWidth": 2,
"trailingComma": "all", "trailingComma": "all",
"endOfLine": "crlf", "useTabs": false,
"jsxSingleQuote": false "vueIndentScriptAndStyle": false,
"overrides": [
{
"files": ["*.json", "*.yml", "*.yaml", "*.md"],
"options": {
"tabWidth": 2
} }
}
],
"endOfLine": "lf"
}

View File

@ -1,34 +0,0 @@
###################
# BUILD FOR LOCAL DEVELOPMENT
###################
FROM node:16-alpine AS development
WORKDIR /app/
COPY --chown=node:node package*.json ./
RUN npm ci
COPY --chown=node:node . .
USER node
###################
# BUILD FOR PRODUCTION
###################
FROM node:16-alpine AS build
WORKDIR /app/
ENV NODE_ENV production
COPY --chown=node:node package*.json ./
COPY --chown=node:node --from=development /app/node_modules ./node_modules
COPY --chown=node:node . .
RUN npm run build
RUN npm ci --only=production && npm cache clean --force
USER node
###################
# PRODUCTION
###################
FROM nginx:stable-alpine AS nginx
COPY --from=build /app/dist/ /usr/share/nginx/html/
COPY --from=build /app/run.sh /app/
COPY --from=build /app/nginx.conf /configs/
RUN chmod +x /app/run.sh
CMD ["/app/run.sh"]

View File

@ -1,35 +0,0 @@
###################
# BUILD FOR LOCAL DEVELOPMENT
###################
FROM node:16-alpine AS development
WORKDIR /app/
COPY --chown=node:node package*.json ./
RUN npm ci
COPY --chown=node:node . .
USER node
###################
# BUILD FOR PRODUCTION
###################
FROM node:16-alpine AS build
WORKDIR /app/
COPY --chown=node:node package*.json ./
COPY --chown=node:node --from=development /app/node_modules ./node_modules
COPY --chown=node:node . .
RUN npm run build
ENV NODE_ENV production
RUN npm ci --only=production && npm cache clean --force
USER node
###################
# PRODUCTION
###################
FROM nginx:stable-alpine AS nginx
ARG PORT=9999
COPY --from=build /app/dist/ /usr/share/nginx/html/
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE ${PORT}
CMD ["nginx", "-g", "daemon off;" ]

View File

@ -1,27 +0,0 @@
# Variables
IMAGE_NAME="demoap-fe:latest"
CONTAINER_NAME="demoap-fe"
PORT="9999"
HTTP_PROXY="http://42.96.40.255:8002" # http:\/\/0.0.0.0:8002
# Make sure that HTTP_PROXY is not empty
if [ -z "$HTTP_PROXY" ]
then
echo "HTTP_PROXY is empty, you have to specify it in deploy.sh file"
exit 1
fi
# Replace #proxy_server in nginx.conf with HTTP_PROXY using sed command
sed -i "s|#proxy_server|$HTTP_PROXY|g" ./nginx.conf
# Replace #port in nginx.conf with PORT using sed command
sed -i "s|#port|$PORT|g" ./nginx.conf
# Build image
docker build --build-arg PORT=$PORT --pull --rm -f "Dockerfile" -t $IMAGE_NAME "."
# Remove exist container
docker container stop $CONTAINER_NAME
# Run container from new image
docker run --rm -d -p $PORT:$PORT/tcp --name $CONTAINER_NAME $IMAGE_NAME

View File

@ -1,12 +1,27 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
<title>OCR</title> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="A ML-Ops platform" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="/manifest.json" />
<title>SBT</title>
<style>
div#root {
height: 100vh;
}
</style>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>

View File

@ -1,61 +0,0 @@
server {
# listen {{port}};
# listen [::]:{{port}};
server_name localhost;
client_max_body_size 100M;
#access_log /var/log/nginx/host.access.log main;
location ~ ^/api {
proxy_pass {{proxy_server}};
proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
}
location /static/drf_spectacular_sidecar/ {
alias /backend-static/drf_spectacular_sidecar/;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri /index.html;
}
location ~ ^/static/drf_spectacular_sidecar/swagger-ui-dist {
proxy_pass {{proxy_server}};
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,57 +1,88 @@
{ {
"name": "vite-project", "name": "sbt-ui",
"private": true, "version": "0.1.0",
"version": "0.0.0",
"type": "module",
"scripts": { "scripts": {
"start": "npm run extract && npm run compile && vite", "start": "npm run extract && npm run compile && vite",
"build": "npm run extract && npm run compile && tsc && vite build", "build": "npm run extract && npm run compile && tsc && vite build",
"serve": "vite preview", "serve": "vite preview",
"extract": "lingui extract --clean", "extract": "lingui extract --clean",
"compile": "lingui compile", "compile": "lingui compile",
"format": "prettier --config ./.prettierrc --write src/**/*.{ts,tsx,js,jsx}" "format": "prettier --config ./.prettierrc --write src/**/*.{ts,tsx,js,jsx}",
"lint": "eslint . --ext .ts,.tsx --fix"
},
"engines": {
"node": ">=16"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}, },
"dependencies": { "dependencies": {
"@ant-design/colors": "^7.0.0", "@ant-design/colors": "^6.0.0",
"@ant-design/icons": "^4.8.0", "@ant-design/icons": "^4.8.0",
"@ant-design/plots": "^1.2.3",
"@ant-design/pro-layout": "^7.10.3",
"@babel/core": "^7.13.10",
"@casl/ability": "^6.3.3",
"@casl/react": "^3.1.0",
"@faker-js/faker": "^8.3.1",
"@heartexlabs/label-studio": "1.4.0",
"@tanstack/react-query": "^4.20.4", "@tanstack/react-query": "^4.20.4",
"antd": "^5.1.2", "antd": "^5.4.0",
"axios": "^1.2.2", "axios": "^1.2.2",
"konva": "^8.3.14", "chart.js": "^4.4.1",
"dagre": "^0.8.5",
"faker": "^6.6.6",
"history": "^5.3.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"pdfjs-dist": "^3.4.120", "mousetrap": "^1.6.5",
"prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-csv": "^2.2.2", "react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-json-view-lite": "^0.9.6",
"react-konva": "^18.2.3",
"react-konva-utils": "^0.3.1",
"react-router-dom": "^6.6.1", "react-router-dom": "^6.6.1",
"reactflow": "^11.4.0",
"styled-components": "^5.3.6", "styled-components": "^5.3.6",
"usehooks-ts": "^2.9.1",
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-syntax-jsx": "^7.18.6", "@babel/plugin-syntax-jsx": "^7.12.13",
"@babel/plugin-transform-react-jsx-self": "^7.12.13",
"@babel/plugin-transform-react-jsx-source": "^7.12.13",
"@babel/preset-typescript": "^7.18.6", "@babel/preset-typescript": "^7.18.6",
"@babel/runtime": "^7.20.13", "@babel/runtime": "^7.13.10",
"@lingui/cli": "^3.17.0", "@lingui/cli": "^3.7.2",
"@lingui/core": "^3.17.0", "@lingui/core": "^3.7.2",
"@lingui/macro": "^3.17.0", "@lingui/macro": "^3.7.2",
"@lingui/react": "^3.17.0", "@lingui/react": "^3.7.2",
"@tanstack/eslint-plugin-query": "^4.29.4",
"@tanstack/react-query-devtools": "^4.20.4",
"@types/babel-plugin-macros": "^2.8.4",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.6",
"@types/react": "^18.0.26", "@types/node": "^18.11.18",
"@types/react-dom": "^18.0.9", "@types/react": "^18.0.20",
"@types/styled-components": "^5.1.26", "@types/react-dom": "^18.0.10",
"@types/uuid": "^9.0.0", "@types/uuid": "^9.0.1",
"@vitejs/plugin-react": "^3.0.0", "@vitejs/plugin-react": "^3.0.0",
"babel-plugin-macros": "^3.1.0", "babel-plugin-macros": "^3.0.1",
"eslint": "^8.40.0",
"eslint-config-prettier": "^8.8.0",
"eslint-config-react-app": "^7.0.1",
"make-plural": "^7.2.0", "make-plural": "^7.2.0",
"prettier": "^2.8.3", "prettier": "^2.8.1",
"prettier-plugin-organize-imports": "^3.2.2", "prettier-plugin-organize-imports": "^3.2.1",
"typescript": "^4.9.3", "rollup-plugin-visualizer": "^5.9.0",
"vite": "^4.0.0", "sass": "^1.57.1",
"vite-tsconfig-paths": "^4.0.5" "typescript": "^4.9.4",
"vite": "^4.0.3",
"vite-plugin-svgr": "^2.4.0",
"vite-tsconfig-paths": "^4.0.3"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -1,21 +1,11 @@
{ {
"short_name": "React App", "short_name": "sbt",
"name": "Create React App Sample", "name": "sbt",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16", "sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon" "type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
} }
], ],
"start_url": ".", "start_url": ".",

File diff suppressed because one or more lines are too long

View File

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -0,0 +1,17 @@
server {
root /usr/share/nginx/html;
location / {
# Any route that doesn't exist on the server (e.g. /devices)
try_files $uri $uri/ /index.html;
add_header Cache-Control: "no-cache, no-store, must-revalidate";
add_header Pragma: "no-cache";
add_header Expires: 0;
}
location /assets {
expires 1y;
add_header Cache-Control "public";
access_log off;
}
}

View File

@ -1,24 +0,0 @@
# Run the project COPEN-FE
- **How to clone the project**:
`git clone http://code.sdsrv.vn/c-ope2n/frontend.git`
- **Change to working directory**:
Open the downloaded project via terminal then re-direct to the `frontend` folder and switch to the `develop` branch:
`cd frontend`
`git checkout develop`
- **Install packages**: Install needed packages/dependencies for running the\*\* project (We need to manually run this every time we add a new dependency to the `package.json` file).
`npm i`
- Start project in developer mode:
`npm start`
- **Environment variables** in `.env` file:
- Development port: VITE_PORT
- Backend API: VITE_PROXY

View File

@ -1,5 +0,0 @@
#!/bin/sh
# update port and BD proxy
sed "s#{{proxy_server}}#$VITE_PROXY#g" /configs/nginx.conf > /etc/nginx/conf.d/default.conf
# run up
nginx -g 'daemon off;'

View File

@ -1,4 +0,0 @@
sonar.projectKey=c-ope2n_frontend_AYb9EnnVvcs_Ifu-neN-
sonar.qualitygate.wait=true
# sonar.login=admin
# sonar.password=trongtai37

View File

@ -1,31 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from 'react-router-dom';
import Internationalization from './components/internaltionalization';
import { createRouter } from './router/createRouter';
import 'antd/dist/reset.css';
import './styles/override.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: 30_000,
staleTime: 10_000,
refetchOnWindowFocus: false,
},
},
});
function App() {
const router = createRouter();
return (
<QueryClientProvider client={queryClient}>
<Internationalization>
<RouterProvider router={router} />
</Internationalization>
</QueryClientProvider>
);
}
export default App;

View File

@ -0,0 +1,44 @@
import {
AbilityBuilder,
AbilityClass,
MatchConditions,
PureAbility,
} from '@casl/ability';
import { Project, User } from 'models';
import { AppAbility } from './types';
const AppAbilityClass = PureAbility as AbilityClass<AppAbility>;
export const appAbilitiy = new AppAbilityClass([], {
conditionsMatcher: (matchConditions: MatchConditions) => matchConditions,
});
export function updateAbility(ability: AppAbility, user: User) {
const { can, cannot, rules } = new AbilityBuilder(AppAbilityClass);
if (user.is_superuser) {
can('manage', 'all');
}
if (user.is_superuser === false) {
can('read', 'Project');
// @ts-ignore
can<Project>(
['manage', 'update', 'delete', 'labeling:confirm', 'training:terminate'],
'Project',
(project: Project) => {
if (Array.isArray(project.admins)) {
if (project.admins.find((item) => item.id === user.id)) {
return true;
}
}
return false;
},
);
cannot('create', 'Project');
}
// update ability
ability.update(rules);
}

View File

@ -0,0 +1,7 @@
import { createContextualCan, useAbility } from '@casl/react';
import * as React from 'react';
import { appAbilitiy } from './ability';
export const AbilityContext = React.createContext(appAbilitiy);
export const Can = createContextualCan(AbilityContext.Consumer);
export const useAppAbility = () => useAbility(AbilityContext);

View File

@ -0,0 +1,3 @@
export * from './ability';
export * from './context';
export * from './types';

View File

@ -0,0 +1,36 @@
import { MatchConditions, PureAbility } from '@casl/ability';
import {
Category,
DataPointMetadata,
Deployment,
Device,
LogRecord,
Project,
RegisteredModel,
RegisteredProgram,
TrainingJob,
User,
} from 'models';
type InferSubjectAs<T, V extends string> = T | V;
export type AppSubjects =
| InferSubjectAs<Category, 'Category'>
| InferSubjectAs<DataPointMetadata, 'ImageMetadata'>
| InferSubjectAs<Deployment, 'Deployment'>
| InferSubjectAs<Device, 'Device'>
| InferSubjectAs<Project, 'Project'>
| InferSubjectAs<RegisteredModel, 'RegisteredModel'>
| InferSubjectAs<RegisteredProgram, 'RegisteredProgram'>
| InferSubjectAs<TrainingJob, 'TrainingJob'>
| InferSubjectAs<User, 'User'>
| InferSubjectAs<LogRecord, 'DeviceResult'>
| 'all';
export type CRUD = 'create' | 'read' | 'update' | 'delete';
export type ConfirmLabel = 'labeling:confirm';
export type TerminateJob = 'training:terminate';
export type AppActions = CRUD | ConfirmLabel | TerminateJob | 'manage';
export type Abilities = [AppActions, AppSubjects];
export type AppAbility = PureAbility<Abilities, MatchConditions>;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,59 +0,0 @@
import * as React from 'react';
import { Html } from 'react-konva-utils';
export interface ControlsProps {
containerRef: React.MutableRefObject<HTMLDivElement>;
hidden?: boolean;
}
const PADDING_OFFSET = 16;
export const Controls = ({
containerRef,
children,
hidden = false,
}: React.PropsWithChildren<ControlsProps>) => {
const position = useControlsPosition(containerRef);
return hidden ? null : (
<Html
divProps={{
style: {
position: 'fixed',
top: `${position.top + PADDING_OFFSET}px`,
left: `${position.left + PADDING_OFFSET}px`,
},
}}
transform={false}
>
{children}
</Html>
);
};
function useControlsPosition(
containerRef: React.MutableRefObject<HTMLDivElement>,
) {
const [position, setPosition] = React.useState({ top: 0, left: 0 });
const observeRef = React.useRef(
new ResizeObserver((entries) => {
const container = entries[0];
if (container) {
setPosition({
top: container.target.getBoundingClientRect().top ?? 0,
left: container.target.getBoundingClientRect().left ?? 0,
});
}
}),
);
React.useEffect(() => {
if (containerRef.current) {
observeRef.current.observe(containerRef.current);
}
return () => {
observeRef.current.disconnect();
};
}, [containerRef.current]);
return position;
}

View File

@ -1,5 +0,0 @@
import { Circle as KonvaCircle, Rect } from 'react-konva';
import { withTransform } from './shapeFactory';
export const Rectangle = withTransform(Rect);
export const Circle = withTransform(KonvaCircle);

View File

@ -1,95 +0,0 @@
import Konva from 'konva';
import React from 'react';
import { KonvaNodeComponent, Transformer } from 'react-konva';
import { TransformableShapeProps } from '../types';
export function withTransform<
TShapeComponent extends KonvaNodeComponent<Konva.Node>,
>(Shape: TShapeComponent) {
return function ({
isTransformable,
isSelected,
onSelect,
onChange,
title,
isAnchor,
...shapeProps
}: TransformableShapeProps<TShapeComponent>) {
const labelRef = React.useRef<Konva.Label>();
const shapeRef = React.useRef<Konva.Node>();
const tranformRef = React.useRef<Konva.Transformer>();
React.useEffect(() => {
if (isSelected) {
const nodes = [shapeRef.current, labelRef.current].filter(Boolean);
tranformRef.current.nodes(nodes);
tranformRef.current.getLayer().batchDraw();
}
}, [isSelected]);
return (
<>
{/* @ts-ignore */}
<Shape
ref={shapeRef}
{...shapeProps}
strokeScaleEnabled={false}
draggable={isTransformable}
onClick={onSelect}
onTap={onSelect}
onDragEnd={(e) => {
onChange({
...shapeProps,
x: e.target.x(),
y: e.target.y(),
});
}}
onTransform={(event) => {
const textNode = labelRef.current;
if (textNode) {
const stageScale = event.currentTarget.getStage().scale().x;
const absScale = labelRef.current?.getAbsoluteScale() || {
x: 1,
y: 1,
};
textNode.scaleX((textNode.scaleX() / absScale.x) * stageScale);
textNode.scaleY((textNode.scaleY() / absScale.y) * stageScale);
}
}}
onTransformEnd={() => {
const node = shapeRef.current;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
onChange({
...shapeProps,
x: node.x(),
y: node.y(),
width: Math.max(5, node.width() * scaleX),
height: Math.max(node.height() * scaleY),
});
}}
fill={isSelected ? shapeProps.fill : null}
/>
{isSelected && (
<Transformer
ref={tranformRef}
ignoreStroke
boundBoxFunc={(oldBox, newBox) => {
if (newBox.width < 5 || newBox.height < 5) {
return oldBox;
}
return newBox;
}}
draggable={isTransformable}
resizeEnabled={isTransformable}
rotateEnabled={isTransformable}
/>
)}
</>
);
};
}

View File

@ -1,107 +0,0 @@
import Konva from 'konva';
import { isNumber } from 'lodash-es';
import React from 'react';
import { KonvaNodeEvents } from 'react-konva';
function standardizeRectConfig(config: Konva.RectConfig): Konva.RectConfig {
if (config.width < 0) {
config.width = Math.abs(config.width);
config.x -= config.width;
}
if (config.height < 0) {
config.height = Math.abs(config.height);
config.y -= config.height;
}
return config;
}
function ensureEvtWithLayer(
evt: any,
): evt is MouseEvent & { layerX: number; layerY: number } {
return (
isNumber(evt.layerX) && isNumber(evt.layerY) && evt instanceof MouseEvent
);
}
export function useDrawStage({
canDraw = true,
onFinish,
}: {
canDraw?: boolean;
onFinish?(config: Konva.RectConfig): void;
}) {
const [isDrawing, setDrawing] = React.useState(false);
const [rectConfig, setRectConfig] = React.useState<Konva.RectConfig>({
x: 0,
y: 0,
width: 0,
height: 0,
stroke: 'red',
fill: 'rgba(255,0,0,0.2)',
strokeWidth: 4,
visible: false,
isAnchor: false,
});
const start: KonvaNodeEvents['onMouseDown'] = React.useCallback(
(event) => {
const evt = event.evt;
if (
canDraw &&
event.target.id() === 'background-image' &&
ensureEvtWithLayer(evt)
) {
const stage = event.currentTarget.getStage();
setDrawing(true);
setRectConfig((prevConfig) => ({
...prevConfig,
x: evt.layerX / stage.scaleX(),
y: evt.layerY / stage.scaleY(),
}));
}
},
[canDraw],
);
const handle: KonvaNodeEvents['onMouseMove'] = React.useCallback(
(event) => {
const evt = event.evt;
if (canDraw && isDrawing && ensureEvtWithLayer(evt)) {
const stage = event.currentTarget.getStage();
setRectConfig((prevConfig) => ({
...prevConfig,
width: evt.layerX / stage.scaleX() - prevConfig.x,
height: evt.layerY / stage.scaleY() - prevConfig.y,
visible: true,
}));
}
},
[canDraw, isDrawing],
);
const finish: KonvaNodeEvents['onMouseUp'] = React.useCallback(() => {
if (canDraw && isDrawing) {
setDrawing(false);
setRectConfig((prevConfig) => ({
...prevConfig,
width: 0,
height: 0,
visible: false,
}));
if (onFinish && rectConfig.width && rectConfig.height) {
onFinish(standardizeRectConfig(rectConfig));
}
}
}, [canDraw, onFinish]);
return {
start,
handle,
finish,
isDrawing,
rect: rectConfig,
};
}

View File

@ -1,4 +0,0 @@
export * from './draw';
export * from './scale';
export * from './store';
export * from './use-load-image';

View File

@ -1,33 +0,0 @@
import React from 'react';
import { StageProps } from 'react-konva';
export const SCALE_FACTOR = 1.1;
export const MIN_SCALE = 1 / 5;
export const MAX_SCALE = 5;
export function useScaleStage(initScale: StageProps['scale'] = { x: 1, y: 1 }) {
const [scale, setScale] = React.useState(initScale);
const scaleIn = React.useCallback(() => {
setScale((prev) => ({
x: Math.min(prev.x * SCALE_FACTOR, MAX_SCALE),
y: Math.min(prev.y * SCALE_FACTOR, MAX_SCALE),
}));
}, []);
const scaleOut = React.useCallback(() => {
setScale((prev) => ({
x: Math.max(prev.x / SCALE_FACTOR, MIN_SCALE),
y: Math.max(prev.y / SCALE_FACTOR, MIN_SCALE),
}));
}, []);
return {
scale,
canZoomIn: scale.x < MAX_SCALE,
canZoomOut: scale.x > MIN_SCALE,
scaleIn,
scaleOut,
scaleTo: setScale,
};
}

View File

@ -1,31 +0,0 @@
import React from 'react';
import { useStoreInstance } from '../store-context';
import {
DefaultShapeData,
ShapeConfig,
StoreSelector,
StoreValue,
} from '../types';
export function useAnnotatorStore<ReturnType>(
selector: StoreSelector<ReturnType> = (internalStore: StoreValue) =>
internalStore as ReturnType,
) {
const store = useStoreInstance();
return React.useSyncExternalStore(store.subscribe, () =>
selector(store.getStore()),
);
}
export function useShapes<TData = DefaultShapeData>() {
// @ts-ignore
return useAnnotatorStore<ShapeConfig<TData>[]>((store) => store.shapes);
}
export function useSelectedShapeId() {
return useAnnotatorStore((store) => store.selectedShapeId);
}
export function useBackground(): string {
return useAnnotatorStore((store) => store.background);
}

View File

@ -1,54 +0,0 @@
import React from 'react';
interface UseLoadImagesOptions {
onSuccess?(image: HTMLImageElement): void;
onError?(): void;
}
const initialLoadState = {
isLoading: true,
isSuccess: false,
isError: false,
};
export function useLoadImage(
src: string,
{ onSuccess, onError }: UseLoadImagesOptions = {},
) {
const imageRef = React.useRef<HTMLImageElement>(new window.Image());
const [loadState, setLoadState] = React.useState(initialLoadState);
React.useEffect(() => {
setLoadState(initialLoadState);
imageRef.current.src = src;
}, [src]);
imageRef.current.addEventListener('load', () => {
setLoadState({
isLoading: false,
isError: false,
isSuccess: true,
});
if (onSuccess) {
onSuccess(imageRef.current);
}
});
imageRef.current.addEventListener('error', () => {
setLoadState({
isLoading: false,
isSuccess: false,
isError: true,
});
if (onError) {
onError();
}
});
return {
...loadState,
image: imageRef.current,
};
}

View File

@ -1,249 +0,0 @@
import {
ExpandOutlined,
ZoomInOutlined,
ZoomOutOutlined,
} from '@ant-design/icons';
import { t } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Button, Empty, Grid, Space, Spin, Tooltip } from 'antd';
import Konva from 'konva';
import React from 'react';
import { Image, Layer, Rect, Stage } from 'react-konva';
import { useIsFirstRender } from 'usehooks-ts';
import { v4 as uuidv4 } from 'uuid';
import { Controls } from './controls';
import { Rectangle } from './custom-shapes';
import {
useBackground,
useDrawStage,
useLoadImage,
useScaleStage,
useSelectedShapeId,
useShapes,
} from './hooks';
import { useStoreInstance } from './store-context';
import { ShapeConfig, ShapeTypes, StoreValue } from './types';
interface ImageAnnotatorProps {
background?: string;
shapes?: ShapeConfig[];
initialBackground?: string;
initialShapes?: ShapeConfig[];
containerWidth?: React.CSSProperties['width'];
containerHeight?: React.CSSProperties['height'];
readOnly?: boolean;
fitToView?: boolean;
}
export function ImageAnnotator({
background,
shapes,
initialBackground,
initialShapes,
containerWidth = '100%',
containerHeight = '100%',
fitToView = true,
readOnly = false,
}: ImageAnnotatorProps) {
const { i18n } = useLingui();
const screens = Grid.useBreakpoint();
const containerRef = React.useRef<HTMLDivElement>();
const stageRef = React.useRef<Konva.Stage>();
const isFirstRender = useIsFirstRender();
const storeInstance = useStoreInstance();
const internalShapes = useShapes();
const internalBackground = useBackground();
const selectedShapeId = useSelectedShapeId();
const { scale, scaleIn, scaleOut, scaleTo, canZoomIn, canZoomOut } =
useScaleStage();
const { start, handle, finish, rect } = useDrawStage({
canDraw: !readOnly,
onFinish: (finalRect) => {
storeInstance.addShapes([
{
id: uuidv4(),
type: ShapeTypes.RECTANGLE,
data: {
label: 'default label',
value: '',
},
...finalRect,
},
]);
},
});
const fitImageWidth = React.useCallback(
(_image: HTMLImageElement, isContainerReady = true) => {
const SCROLL_BAR_WIDTH_PX = 15;
const viewWidth =
containerRef.current.clientWidth -
(isContainerReady ? 0 : SCROLL_BAR_WIDTH_PX);
const imageWidth = _image.naturalWidth;
const newScale = viewWidth / imageWidth;
scaleTo({
x: newScale,
y: newScale,
});
},
[scaleTo],
);
const { image, isLoading } = useLoadImage(internalBackground, {
onSuccess(_image) {
if (fitToView) {
fitImageWidth(_image, false);
}
},
});
React.useEffect(() => {
const newStore = getStoreChangeFromDeriveProps({
shapes,
background,
});
const firstStore: Partial<StoreValue> = {};
if (initialBackground && isFirstRender) {
firstStore.background = initialBackground;
}
if (initialShapes && isFirstRender) {
firstStore.shapes = initialShapes;
}
storeInstance.setStore((prev) => ({
...prev,
...firstStore,
...newStore,
}));
}, [shapes, background, isFirstRender]);
return (
<div
ref={containerRef}
style={{
width: containerWidth,
height: containerHeight,
position: 'relative',
overflow: 'auto',
border: '1px dashed #d9d9d9',
borderRadius: '8px',
}}
>
{internalBackground ? (
<Stage
ref={stageRef}
scale={scale}
width={image.naturalWidth * scale.x}
height={image.naturalHeight * scale.y}
onMouseDown={(event) => {
const isClickedOnEmpty = event.target.id() === 'background-image';
if (isClickedOnEmpty) {
storeInstance.setSelectedShape(null);
start(event);
}
}}
onMouseMove={handle}
onMouseUp={finish}
>
<Layer>
<Image
image={image}
width={image.naturalWidth}
height={image.naturalHeight}
id="background-image"
/>
{internalShapes.map((shape) => (
<Rectangle
{...shape}
isSelected={shape.id === selectedShapeId}
isTransformable={!readOnly}
onSelect={() => storeInstance.setSelectedShape(shape.id)}
onChange={(shapeConfig) => {
const newShapes = internalShapes.map((item) =>
item.id === shape.id
? {
...shape,
...shapeConfig,
}
: item,
);
storeInstance.setShapes(newShapes);
}}
draggable={!readOnly}
key={shape.id}
/>
))}
<Rect {...rect} />
<Controls containerRef={containerRef} hidden={!screens.lg}>
<Space>
<Tooltip title={t(i18n)`Zoom out`}>
<Button
shape="circle"
icon={<ZoomOutOutlined />}
onClick={scaleOut}
disabled={!canZoomOut}
/>
</Tooltip>
<Tooltip title={t(i18n)`Zoom in`}>
<Button
shape="circle"
icon={<ZoomInOutlined />}
onClick={scaleIn}
disabled={!canZoomIn}
/>
</Tooltip>
<Tooltip title={t(i18n)`Fit image`}>
<Button
shape="circle"
icon={<ExpandOutlined />}
onClick={() => fitImageWidth(image)}
/>
</Tooltip>
</Space>
</Controls>
</Layer>
</Stage>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t(i18n)`No data, please upload an image.`}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
)}
{isLoading && (
<Spin
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
)}
</div>
);
}
function getStoreChangeFromDeriveProps({
background,
shapes,
}: Pick<ImageAnnotatorProps, 'background' | 'shapes'>) {
const newStore: Partial<StoreValue> = {};
if (background !== undefined) {
newStore.background = background;
}
if (shapes !== undefined) {
newStore.shapes = shapes;
}
return newStore;
}

View File

@ -1,23 +0,0 @@
import React from 'react';
import { AnnotatorStore } from './store';
const defaultStore = new AnnotatorStore();
const AnnotatorContext = React.createContext(defaultStore);
export const useStoreInstance = () => React.useContext(AnnotatorContext);
export function AnnotatorProvider({
store,
children,
}: React.PropsWithChildren<{
store?: AnnotatorStore;
}>) {
const storeRef = React.useRef(new AnnotatorStore());
return (
<AnnotatorContext.Provider value={store || storeRef.current}>
{children}
</AnnotatorContext.Provider>
);
}

View File

@ -1,67 +0,0 @@
import { SetState } from '../../models';
import { ShapeConfig, StoreValue, SubcribeHandler } from './types';
export class AnnotatorStore {
private store: StoreValue = {
background: '',
shapes: [],
selectedShapeId: null,
};
private subscribers = new Set<SubcribeHandler>();
private notify() {
this.subscribers.forEach((handler) => handler(this.store));
}
subscribe = (handler: SubcribeHandler) => {
this.subscribers.add(handler);
return () => {
this.subscribers.delete(handler);
};
};
addShapes(shapes: ShapeConfig[]) {
this.store.shapes = this.store.shapes.concat(shapes);
this.notify();
}
removeShapes(shapeIds: ShapeConfig['id'][]) {
this.store.shapes = this.store.shapes.filter(
(shape) => !shapeIds.includes(shape.id),
);
this.notify();
}
setShapes(shapesHandler: SetState<ShapeConfig[]>) {
this.store.shapes =
typeof shapesHandler === 'function'
? shapesHandler(this.store.shapes)
: shapesHandler;
this.notify();
}
setBackground(background: string) {
this.store.background = background;
this.notify();
}
setSelectedShape(shapeId: ShapeConfig['id'] | null) {
this.store.selectedShapeId = shapeId;
this.notify();
}
setStore(storeHandler: SetState<StoreValue>) {
this.store =
typeof storeHandler === 'function'
? storeHandler(this.store)
: storeHandler;
this.notify();
}
getStore(): StoreValue {
return this.store;
}
}

View File

@ -1,60 +0,0 @@
import Konva from 'konva';
import { KonvaNodeComponent } from 'react-konva';
// Shapes
export enum ShapeTypes {
RECTANGLE = 'reactangle',
CIRCLE = 'circle',
}
export interface DefaultShapeData {
label: string;
value: string;
}
export type WithCustomConfig<
TConfig extends Konva.ShapeConfig,
TData = DefaultShapeData,
> = TConfig & {
id: string;
data?: TData;
} & WithTransformProps<TConfig>;
export type RectangleConfig<TData = DefaultShapeData> = {
type: ShapeTypes.RECTANGLE;
} & WithCustomConfig<Konva.RectConfig, TData>;
export type CircleConfig<TData = DefaultShapeData> = {
type: ShapeTypes.CIRCLE;
} & WithCustomConfig<Konva.CircleConfig, TData>;
export type ShapeConfig<TData = DefaultShapeData> =
| RectangleConfig<TData>
| CircleConfig<TData>;
// Store
export type SubcribeHandler = (store: StoreValue) => void;
export type StoreSelector<ReturnType> = (store: StoreValue) => ReturnType;
export interface StoreValue {
background: string;
shapes: ShapeConfig[];
selectedShapeId: ShapeConfig['id'] | null;
}
// Transformable
export type TransformableShapeProps<
TShapeComponent extends KonvaNodeComponent<Konva.Node>,
> = TShapeComponent extends KonvaNodeComponent<Konva.Node, infer Props>
? Props & WithTransformProps<Props>
: never;
export type WithTransformProps<
TShapeConfig extends Konva.ShapeConfig = Konva.ShapeConfig,
> = {
isTransformable?: boolean;
isSelected?: boolean;
onSelect?(): void;
onChange?(shapeConfig: TShapeConfig): void;
title?: string;
isAnchor?: boolean;
};

View File

@ -1,33 +0,0 @@
import { Typography } from "antd";
export const Brand = ({
collapsed,
isBordered = true,
}: {
collapsed: boolean;
isBordered?: boolean;
}) => {
return (
<Typography.Title
ellipsis={{
rows: 1,
}}
style={{
marginTop: 3,
marginLeft: 12,
marginRight: 12,
paddingTop: 18,
paddingBottom: 18,
letterSpacing: 2,
borderBottomLeftRadius: 10,
borderBottomRightRadius: 10,
color: "#fff",
textAlign: "center",
backgroundColor: "rgb(0, 106, 255)",
fontSize: 32,
}}
>
{collapsed ? "O" : "OCR"}
</Typography.Title>
);
};

View File

@ -0,0 +1,2 @@
export { default as MultiTypeChart } from './multitype-chart';
export { default as PieChart } from './pie-chart';

View File

@ -0,0 +1,41 @@
import {
BarController,
BarElement,
CategoryScale,
Chart as ChartJS,
ChartData,
ChartOptions,
ChartTypeRegistry,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
} from 'chart.js';
import { Chart } from 'react-chartjs-2';
ChartJS.register(
LinearScale,
CategoryScale,
BarElement,
PointElement,
LineElement,
Legend,
Tooltip,
LineController,
BarController,
);
interface MultiChartProps {
type: keyof ChartTypeRegistry;
data: ChartData;
options?: ChartOptions;
height: number;
}
export default function MultiTypeChart(props: MultiChartProps) {
const { type, data, options, height } = props;
return <Chart type={type} data={data} height={height} options={options} />;
}

View File

@ -0,0 +1,20 @@
import {
ArcElement,
Chart as ChartJS,
ChartData,
ChartOptions,
Legend,
Tooltip,
} from 'chart.js';
import { Pie } from 'react-chartjs-2';
ChartJS.register(ArcElement, Tooltip, Legend);
interface PieChartProps {
data: ChartData<'pie'>;
options?: ChartOptions<'pie'>;
}
export default function PieChart({ data, options }: PieChartProps) {
return <Pie data={data} options={options} />;
}

View File

@ -0,0 +1,16 @@
import { Collapse as AntCollapse, CollapseProps, theme } from 'antd';
import styled from 'styled-components';
function Collapse(props: CollapseProps) {
return <AntCollapse {...props} />;
}
const Panel = styled(AntCollapse.Panel)`
& .sbt-collapse-header:hover {
color: ${theme.defaultConfig.token.colorPrimary};
}
`;
Collapse.Panel = Panel;
export { Collapse };

View File

@ -0,0 +1,13 @@
import React from 'react';
interface DisplayNoneProps {
enabled?: boolean;
children: React.ReactNode;
}
export const DisplayNone: React.FC<DisplayNoneProps> = ({
enabled = false,
children,
}) => {
return <div style={{ display: enabled ? 'none' : '' }}>{children}</div>;
};

View File

@ -1,11 +0,0 @@
import { Typography } from 'antd';
import styled from 'styled-components';
export const EditableCell = styled(Typography.Text)`
margin-top: 0 !important;
margin-bottom: 0 !important;
& > .ant-typography-copy {
margin-left: 8px;
}
`;

View File

@ -1,28 +0,0 @@
import { Typography } from 'antd';
import React from 'react';
export type EllipsisTitleProps = React.ComponentProps<typeof Typography.Title>;
export const EllipsisTitle = (props: EllipsisTitleProps) => {
const [isEllipsis, setEllipsis] = React.useState(false);
return (
<Typography.Title
{...props}
ellipsis={
props.ellipsis
? {
...(typeof props.ellipsis === 'object' ? props.ellipsis : {}),
onEllipsis(value) {
setEllipsis(value);
if (typeof props.ellipsis === 'object') {
props.ellipsis.onEllipsis(value);
}
},
}
: false
}
title={isEllipsis ? String(props.children) : ''}
/>
);
};

View File

@ -1 +0,0 @@
export * from './ellipsis-title';

View File

@ -0,0 +1,136 @@
import { t } from '@lingui/macro';
import { Button, Result, Typography } from 'antd';
import { AxiosError } from 'axios';
import * as React from 'react';
import { NavigateFunction, useNavigate } from 'react-router-dom';
interface WithRouteProps {
navigate: NavigateFunction;
}
function withRouter<T extends WithRouteProps = WithRouteProps>(
WrappedComponent: React.ComponentType<T>,
) {
const displayName =
WrappedComponent.displayName || WrappedComponent.name || 'Component';
const ComponentWithRouter = (props: Omit<T, keyof WithRouteProps>) => {
const themeProps = { navigate: useNavigate() };
return <WrappedComponent {...themeProps} {...(props as T)} />;
};
ComponentWithRouter.displayName = `withRouter(${displayName})`;
return ComponentWithRouter;
}
interface ErrorBoundaryProps extends WithRouteProps {
children?: React.ReactNode;
imperativeError?: AxiosError | null;
resetImperativeError?: () => void;
}
type ErrorBoundaryState =
| { error: null; errorInfo: null }
| { error: AxiosError; errorInfo: null }
| { error: Error; errorInfo: React.ErrorInfo };
class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
error: null,
errorInfo: null,
};
this.resetError = this.resetError.bind(this);
}
resetError() {
this.setState({
error: null,
errorInfo: null,
});
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
this.setState({
error,
errorInfo,
});
}
componentWillReceiveProps(nextProps: Readonly<ErrorBoundaryProps>): void {
if (nextProps.imperativeError) {
this.setState({
error: nextProps.imperativeError,
errorInfo: null,
});
} else {
this.resetError();
}
}
renderServerInternalError() {
return (
<Result
status='500'
title='500'
subTitle={t`Sorry, something went wrong.`}
extra={
<Button
type='primary'
onClick={() => {
this.resetError();
this.props.navigate('/dashboard');
}}
>{t`Back to Dashboard`}</Button>
}
>
<Typography.Paragraph>
<Typography.Text strong>
{this.state.error?.toString()}
</Typography.Text>
</Typography.Paragraph>
<Typography.Paragraph>
<Typography.Text style={{ whiteSpace: 'pre-wrap' }}>
{t`Something went wrong.`}
</Typography.Text>
</Typography.Paragraph>
</Result>
);
}
renderServerMaintainanceError() {
return (
<Result
status='500'
title='503'
subTitle={t`Service temporarily unavailable.`}
extra={
<Button
type='primary'
onClick={this.props.resetImperativeError}
>{t`Retry`}</Button>
}
/>
);
}
render(): React.ReactNode {
if (this.state.error instanceof AxiosError) {
return this.renderServerMaintainanceError();
}
if (this.state.error) {
return this.renderServerInternalError();
}
return this.props.children;
}
}
export default withRouter(ErrorBoundary);

View File

@ -0,0 +1,47 @@
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { t } from '@lingui/macro';
import { Modal } from 'antd';
import { ANT_PREFIX_CLASS } from 'consts';
import React, { useEffect, useRef, useState } from 'react';
import { unstable_useBlocker as useBlocker } from 'react-router-dom';
function FormBlocker({ block }) {
const [isBlocking, setIsBlocking] = useState(false);
const [hasShownModal, setHasShownModal] = useState(false); // prevent multiple modals from showing
const nextLocationRef = useRef(null);
useBlocker((transition) => {
if (block) {
setIsBlocking(true);
nextLocationRef.current = transition.nextLocation;
setHasShownModal(false);
return !isBlocking;
}
return false;
});
useEffect(() => {
if (isBlocking && !hasShownModal) {
setHasShownModal(true);
Modal.confirm({
title: t`You have unsaved changes!`,
content: t`Are you sure you want to leave without saving?`,
icon: <ExclamationCircleOutlined />,
closable: true,
prefixCls: ANT_PREFIX_CLASS,
onOk() {
setIsBlocking(true);
window.location.href = nextLocationRef.current.pathname;
},
onCancel() {
setIsBlocking(false);
},
});
}
}, [isBlocking, nextLocationRef, hasShownModal]);
return <div key={block as unknown as React.Key}></div>;
}
export default FormBlocker;

View File

@ -0,0 +1,42 @@
import Mousetrap from 'mousetrap';
import { useEffect } from 'react';
export interface KeyMapItem {
name: string;
sequences: string[];
action: 'keydown' | 'keyup' | 'keypress';
}
export type KeyMap = Record<string, KeyMapItem>;
export type Handlers<TKeyMap extends KeyMap> = {
[key in keyof TKeyMap]: (event: KeyboardEvent, shortcut: string) => void;
};
interface Props<TKeyMap extends KeyMap> {
children?: JSX.Element;
keyMap: TKeyMap;
handlers: Handlers<TKeyMap>;
}
export default function GlobalHotKeys<TKeyMap extends KeyMap>(
props: Props<TKeyMap>,
): JSX.Element {
const { children, keyMap, handlers } = props;
useEffect(() => {
for (const key of Object.keys(keyMap)) {
const { sequences, action } = keyMap[key];
const handler = handlers[key];
Mousetrap.bind(sequences, handler, action);
}
return () => {
for (const key of Object.keys(keyMap)) {
const { sequences, action } = keyMap[key];
Mousetrap.unbind(sequences, action);
}
};
});
return children || <></>;
}

View File

@ -1,50 +1,84 @@
import { i18n } from '@lingui/core'; import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react'; import { I18nProvider } from '@lingui/react';
import { ConfigProvider } from 'antd'; import { ConfigProvider } from 'antd';
import { Locale } from 'antd/es/locale-provider'; import { Locale } from 'antd/es/locale';
import enUS from 'antd/es/locale/en_US'; import enUS from 'antd/es/locale/en_US';
import viVN from 'antd/es/locale/vi_VN'; import viVN from 'antd/es/locale/vi_VN';
import { ANT_PREFIX_CLASS } from 'consts';
import 'dayjs/locale/en';
import 'dayjs/locale/vi';
import { dynamicActivate, getLocale } from 'i18n';
import * as React from 'react'; import * as React from 'react';
import { useLocalStorage } from 'usehooks-ts'; import { colors, shapes, typography } from 'theme/opus';
import { LOCALE_KEY } from '../../consts'; import { useGlobalState, useLocalStorage } from 'utils/hooks';
import { dynamicActivate, getLocale } from '../../i18n';
function enhanceAntdViLocale(locale: Locale): Locale {
return {
...locale,
Text: {
copied: 'Đã sao chép',
copy: 'Sao chép',
edit: 'Chỉnh sửa',
expand: 'Mở rộng',
},
};
}
const antdLocales: Record<string, Locale> = { const antdLocales: Record<string, Locale> = {
en: enUS, en: enUS,
vi: enhanceAntdViLocale(viVN), vi: viVN,
}; };
interface InternationalizationProps { interface InternationalizationProps {
children: React.ReactNode; children: React.ReactNode;
} }
const theme: React.ComponentProps<typeof ConfigProvider>['theme'] = {
token: {
// colorPrimary: colors.primary,
colorBorder: colors.border.default,
// fontFamily: typography.fontFamily,
fontSize: typography.fontSize.base,
// fontSizeHeading1: typography.fontSize.h1,
// fontSizeHeading2: typography.fontSize.h2,
// fontSizeHeading3: typography.fontSize.h3,
// fontSizeHeading4: typography.fontSize.h4,
// fontSizeHeading5: typography.fontSize.h5,
fontWeightStrong: typography.fontWeight.bold,
borderRadius: shapes.borderRadius.button,
colorLink: typography.fontColor.hyperlink,
colorTextBase: typography.fontColor.default,
// lineHeight: typography.lineHeight.bodyText,
// lineHeightHeading1: typography.lineHeight.title,
// lineHeightHeading2: typography.lineHeight.title,
// lineHeightHeading3: typography.lineHeight.title,
// lineHeightHeading4: typography.lineHeight.title,
// lineHeightHeading5: typography.lineHeight.title,
},
inherit: true,
components: {
Input: {
controlHeight: 38,
},
Select: {
controlHeight: 38,
},
DatePicker: {
controlHeight: 38,
},
InputNumber: {
controlHeight: 38,
},
},
};
const Internationalization = ({ children }: InternationalizationProps) => { const Internationalization = ({ children }: InternationalizationProps) => {
const [localeStorage] = useLocalStorage(LOCALE_KEY, getLocale()); const [localeStorage] = useLocalStorage('sbt-locale', getLocale());
const { data: locale } = useGlobalState(['sbt-locale'], localeStorage);
React.useEffect(() => { React.useEffect(() => {
dynamicActivate(localeStorage); dynamicActivate(locale);
}, [localeStorage]); }, [locale]);
const antdLocale = React.useMemo( const antdLocale = React.useMemo(() => antdLocales[locale], [locale]);
() => antdLocales[localeStorage],
[localeStorage],
);
return ( return (
<I18nProvider i18n={i18n} forceRenderOnLocaleChange={false}> <I18nProvider i18n={i18n} forceRenderOnLocaleChange>
<ConfigProvider locale={antdLocale}>{children}</ConfigProvider> <ConfigProvider
locale={antdLocale}
theme={theme}
prefixCls={ANT_PREFIX_CLASS}
>
{children}
</ConfigProvider>
</I18nProvider> </I18nProvider>
); );
}; };

View File

@ -1,41 +0,0 @@
import { Segmented } from "antd";
import styled from "styled-components";
import { useLocalStorage } from "usehooks-ts";
import { LOCALE_KEY } from "../../consts";
import { dynamicActivate, getLocale } from "../../i18n";
const StyledSegmented = styled(Segmented)`
& label.ant-segmented-item.ant-segmented-item-selected,
& .ant-segmented-thumb {
color: white;
background-color: #001529;
}
& .ant-segmented-item {
transition: #001529 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
`;
export const LanguageSelect = () => {
const [locale, setLocale] = useLocalStorage(LOCALE_KEY, getLocale());
return (
<StyledSegmented
options={[
{
label: "EN",
value: "en",
},
{
label: "VI",
value: "vi",
},
]}
value={locale}
onChange={(value: string) => {
dynamicActivate(value);
setLocale(value);
}}
/>
);
};

View File

@ -0,0 +1,82 @@
import {
AppstoreOutlined,
BarChartOutlined,
FileSearchOutlined,
QuestionCircleOutlined,
RotateRightOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
import { t } from '@lingui/macro';
import { Menu, MenuProps } from 'antd';
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
type MenuItem = Required<MenuProps>['items'][number];
function useGetMenuItem() {
const navigate = useNavigate();
return function getItem(
label: React.ReactNode,
key: React.Key,
icon?: React.ReactNode,
children?: MenuItem[],
type?: 'group',
): MenuItem {
return {
key,
icon,
children,
label,
type,
onClick({ key: clickedKey }) {
navigate(clickedKey);
},
} as MenuItem;
};
}
function LeftMenu() {
const location = useLocation();
const getItem = useGetMenuItem();
const generalSubItems = [
getItem(t`Dashboard`, '/dashboard', <AppstoreOutlined />),
getItem(t`Inference`, '/inference', <RotateRightOutlined />),
getItem(t`Reviews`, '/reviews', <FileSearchOutlined />),
getItem(t`Reports`, '/reports', <BarChartOutlined />),
getItem(t`Users`, '/users', <UsergroupAddOutlined />),
];
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
height: '100%',
}}
>
<Menu
mode='vertical'
selectedKeys={[location.pathname]}
style={{ borderRight: 'none' }}
items={generalSubItems}
/>
<div
style={{
display: 'flex',
alignItems: 'center',
marginLeft: '20px',
marginBottom: '20px',
gap: '10px',
cursor: 'pointer',
}}
>
<QuestionCircleOutlined />
<span>Help</span>
</div>
</div>
);
}
export default React.memo(LeftMenu);

View File

@ -0,0 +1,79 @@
import {
GlobalOutlined,
LogoutOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { t } from '@lingui/macro';
import { Dropdown, Menu } from 'antd';
import { useAuth } from 'hooks/useAuth';
import { DEFAULT_LOCALE } from 'i18n';
import { useNavigate } from 'react-router-dom';
import { useGlobalState, useLocalStorage } from 'utils/hooks';
const UserHeader = () => {
const { user, logOut } = useAuth();
const navigate = useNavigate();
const [, setLocaleStorage] = useLocalStorage('sbt-locale', DEFAULT_LOCALE);
const { data: locale, setData: setLocale } = useGlobalState(
['sbt-locale'],
DEFAULT_LOCALE,
);
if (!user) {
return null;
}
const userMenu = (
<Menu className='sbt-header-menu' selectedKeys={[locale]}>
<Menu.SubMenu
key='language'
icon={<GlobalOutlined />}
title={<span>{t`Language`}</span>}
>
<Menu.Item
key='vi'
onClick={() => {
setLocaleStorage('vi');
setLocale('vi');
}}
>
{t`Vietnamese`}
</Menu.Item>
<Menu.Item
key='en'
onClick={() => {
setLocaleStorage('en');
setLocale('en');
}}
>
{t`English`}
</Menu.Item>
</Menu.SubMenu>
<Menu.Item
key='logout'
icon={<LogoutOutlined />}
onClick={async () => {
await logOut();
navigate('/auth/login');
}}
>
{t`Logout`}
</Menu.Item>
</Menu>
);
return (
<>
<Dropdown
placement='bottomRight'
overlay={userMenu}
arrow={{ pointAtCenter: true }}
>
<SettingOutlined style={{ cursor: 'pointer' }} />
</Dropdown>
</>
);
};
export default UserHeader;

View File

@ -0,0 +1,22 @@
import { PageHeader, PageHeaderProps } from '@ant-design/pro-layout';
import { Typography } from 'antd';
export type SbtPageHeaderProps = Omit<PageHeaderProps, 'breadcrumb'> & {
routes?: any;
};
export const SbtPageHeader = ({
title,
routes = [],
...remainProps
}: SbtPageHeaderProps) => {
return (
<PageHeader
title={<Typography.Title level={3}>{title}</Typography.Title>}
style={{
padding: '8px 0',
}}
{...remainProps}
/>
);
};

View File

@ -0,0 +1,4 @@
export { default as ReportDetailTable } from './report-detail-table';
export { default as ReportInformation } from './report-information';
export { default as ReportMultiTypeChart } from './report-multitype-chart';
export * from './report-pie-chart';

View File

@ -0,0 +1,319 @@
import type { TableColumnsType } from 'antd';
import { Table } from 'antd';
import React from 'react';
interface DataType {
key: React.Key;
subSidiaries: string;
extractionDate: string | Date;
snOrImeiNumber: number;
invoiceNumber: number;
totalImages: number;
successfulNumber: number;
successfulPercentage: number;
badNumber: number;
badPercentage: number;
snImeiAAR: number; // AAR: Average Accuracy Rate
purchaseDateAAR: number;
retailerNameAAR: number;
snImeiAPT: number; // APT: Average Processing Time
invoiceAPT: number;
snImeiTC: number; // TC: transaction count
invoiceTC: number;
}
const columns: TableColumnsType<DataType> = [
{
title: 'Subsidiaries',
dataIndex: 'subSidiaries',
key: 'subSidiaries',
fixed: 'left',
width: '100px',
},
{
title: 'OCR extraction date',
dataIndex: 'extractionDate',
key: 'extractionDate',
fixed: 'left',
width: '130px',
},
{
title: 'OCR Images',
children: [
{
title: 'SN/IMEI',
dataIndex: 'snOrImeiNumber',
key: 'snOrImeiNumber',
width: '50px',
},
{
title: 'Invoice',
dataIndex: 'invoiceNumber',
key: 'invoiceNumber',
width: '50px',
},
],
},
{
title: 'Total Images',
dataIndex: 'totalImages',
key: 'totalImages',
fixed: 'left',
width: '130px',
},
{
title: 'Image Quality',
children: [
{
title: 'Successful',
dataIndex: 'successfulNumber',
key: 'successfulNumber',
width: '50px',
},
{
title: '% Successful',
dataIndex: 'successfulPercentage',
key: 'successfulPercentage',
width: '120px',
},
{
title: 'Bad',
dataIndex: 'badNumber',
key: 'badNumber',
width: '30px',
},
{
title: '% Bad',
dataIndex: 'badPercentage',
key: 'badPercentage',
width: '60px',
},
],
},
{
title: 'Average accuracy rate (%) \n(※ character-based)',
children: [
{
title: 'IMEI / Serial no.',
dataIndex: 'snImeiAAR',
key: 'snImeiAAR',
width: '130px',
},
{
title: 'Purchase date',
dataIndex: 'purchaseDateAAR',
key: 'purchaseDateAAR',
width: '130px',
},
{
title: 'Retailer name',
dataIndex: 'retailerNameAAR',
key: 'retailerNameAAR',
width: '130px',
},
],
},
{
title: 'Average Processing Time',
children: [
{
title: 'SN/IMEI',
dataIndex: 'snImeiAPT',
key: 'snImeiAPT',
},
{
title: 'Invoice',
dataIndex: 'invoiceAPT',
key: 'invoiceAPT',
},
],
},
{
title: 'Usage (Transaction Count)',
children: [
{
title: 'SN/IMEI',
dataIndex: 'snImeiTC',
key: 'snImeiTC',
},
{
title: 'Invoice',
dataIndex: 'invoiceTC',
key: 'invoiceTC',
},
],
},
];
const data: DataType[] = [];
for (let i = 0; i < 10; i++) {
data.push({
key: i,
subSidiaries: '',
extractionDate: 'SubTotal (Jan)',
snOrImeiNumber: null,
invoiceNumber: null,
totalImages: Math.floor(Math.random() * 100),
successfulNumber: Math.floor(Math.random() * 100),
successfulPercentage: Math.floor(Math.random() * 100),
badNumber: Math.floor(Math.random() * 100),
badPercentage: Math.floor(Math.random() * 100),
snImeiAAR: null,
purchaseDateAAR: null,
retailerNameAAR: null,
snImeiAPT: null,
invoiceAPT: null,
snImeiTC: Math.floor(Math.random() * 100),
invoiceTC: Math.floor(Math.random() * 100),
});
}
const expandedRowRender = () => {
const subData = [];
for (let i = 0; i < 5; ++i) {
subData.push({
key: i,
subSidiaries: 'SESP',
extractionDate: 'Jan',
snOrImeiNumber: Math.floor(Math.random() * 100),
invoiceNumber: Math.floor(Math.random() * 100),
totalImages: Math.floor(Math.random() * 100),
successfulNumber: Math.floor(Math.random() * 100),
successfulPercentage: Math.floor(Math.random() * 100),
badNumber: Math.floor(Math.random() * 100),
badPercentage: Math.floor(Math.random() * 100),
snImeiAAR: Math.floor(Math.random() * 100),
purchaseDateAAR: Math.floor(Math.random() * 100),
retailerNameAAR: Math.floor(Math.random() * 100),
snImeiAPT: Math.floor(Math.random() * 100),
invoiceAPT: Math.floor(Math.random() * 100),
snImeiTC: Math.floor(Math.random() * 100),
invoiceTC: Math.floor(Math.random() * 100),
});
}
const subColumns: TableColumnsType<DataType> = [
{
title: 'Subsidiaries',
dataIndex: 'subSidiaries',
key: 'subSidiaries',
fixed: 'left',
width: '100px',
},
{
title: 'OCR extraction date',
dataIndex: 'extractionDate',
key: 'extractionDate',
fixed: 'left',
width: '130px',
},
{
title: 'SN/IMEI',
dataIndex: 'snOrImeiNumber',
key: 'snOrImeiNumber',
width: '50px',
},
{
title: 'Invoice',
dataIndex: 'invoiceNumber',
key: 'invoiceNumber',
width: '50px',
},
{
title: 'Total Images',
dataIndex: 'totalImages',
key: 'totalImages',
fixed: 'left',
width: '130px',
},
{
title: 'Successful',
dataIndex: 'successfulNumber',
key: 'successfulNumber',
width: '50px',
},
{
title: '% Successful',
dataIndex: 'successfulPercentage',
key: 'successfulPercentage',
width: '120px',
},
{
title: 'Bad',
dataIndex: 'badNumber',
key: 'badNumber',
width: '30px',
},
{
title: '% Bad',
dataIndex: 'badPercentage',
key: 'badPercentage',
width: '60px',
},
{
title: 'IMEI / Serial no.',
dataIndex: 'snImeiAAR',
key: 'snImeiAAR',
width: '130px',
},
{
title: 'Purchase date',
dataIndex: 'purchaseDateAAR',
key: 'purchaseDateAAR',
width: '130px',
},
{
title: 'Retailer name',
dataIndex: 'retailerNameAAR',
key: 'retailerNameAAR',
width: '130px',
},
{
title: 'SN/IMEI',
dataIndex: 'snImeiAPT',
key: 'snImeiAPT',
},
{
title: 'Invoice',
dataIndex: 'invoiceAPT',
key: 'invoiceAPT',
},
{
title: 'SN/IMEI',
dataIndex: 'snImeiTC',
key: 'snImeiTC',
},
{
title: 'Invoice',
dataIndex: 'invoiceTC',
key: 'invoiceTC',
},
];
return (
<Table
columns={subColumns}
dataSource={subData}
pagination={false}
bordered
// showHeader={false}
/>
);
};
const ReportDetailTable: React.FC = () => (
<>
<Table
columns={columns}
dataSource={data}
bordered
size='small'
expandable={{ expandedRowRender, defaultExpandedRowKeys: [0, 1] }}
/>
</>
);
export default ReportDetailTable;

View File

@ -0,0 +1,59 @@
import { Typography } from 'antd';
import styled from 'styled-components';
const Container = styled.div`
display: flex;
border-bottom: 1px solid #ccc;
padding: 8px 0;
margin-top: 8px;
`;
function ReportInformation() {
const data = [
{
title: 'Created date',
content: '11/11/2024',
},
{
title: 'No. requests',
content: '150',
},
{
title: 'No. images',
content: '200',
},
{
title: 'No. bad quality images',
content: '5',
},
{
title: 'Avg client request time',
content: '3000',
},
{
title: 'Avg OCR process time',
content: '1500',
},
{
title: 'Avg accuracy',
content: '11/11/2024',
},
];
return (
<>
{data.map((item) => (
<Container key={item.title}>
<Typography.Title
level={5}
style={{ marginBottom: 0, minWidth: '250px' }}
>
{item.title}
</Typography.Title>
<Typography.Text>{item.content}</Typography.Text>
</Container>
))}
</>
);
}
export default ReportInformation;

View File

@ -0,0 +1,39 @@
import { MultiTypeChart } from 'components/charts';
function ReportMultiTypeChart() {
const data = {
labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
datasets: [
{
type: 'line' as const,
label: 'Dataset 1',
borderColor: 'rgb(255, 99, 132)',
borderWidth: 2,
fill: false,
data: [50, 50, 50, 50, 50, 50, 50],
},
{
type: 'line' as const,
label: 'Dataset 2',
backgroundColor: 'rgb(75, 192, 192)',
data: [10, 20, 30, 40, 50, 60, 70],
borderWidth: 2,
fill: false,
},
],
};
const options = {
scales: {
y: {
beginAtZero: true,
},
},
};
return (
<div>
<MultiTypeChart type='bar' height={320} data={data} options={options} />
</div>
);
}
export default ReportMultiTypeChart;

View File

@ -0,0 +1,26 @@
import { PieChart } from 'components/charts';
function ReportPieChart() {
const data = {
labels: ['Red', 'Blue', 'Yellow'],
datasets: [
{
label: 'My First Dataset',
data: [300, 50, 100],
backgroundColor: [
'rgb(255, 99, 132)',
'rgb(54, 162, 235)',
'rgb(255, 205, 86)',
],
hoverOffset: 4,
},
],
};
return (
<div>
<PieChart data={data} />
</div>
);
}
export default ReportPieChart;

View File

@ -0,0 +1,149 @@
import type { TableColumnsType } from 'antd';
import { Button, Table } from 'antd';
import { ReportDetail } from 'models';
import { useReportList } from 'queries/report';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
const ReportTable: React.FC = () => {
const { isLoading, data: reportData } = useReportList({
page_size: 100,
});
const report_data: any = reportData;
const navigate = useNavigate();
const [pagination, setPagination] = useState(() => ({
page: reportData?.page.total_pages || 1,
page_size: 10,
}));
const columns: TableColumnsType<ReportDetail> = [
{
title: 'ID',
dataIndex: 'ID',
key: 'ID',
sorter: (a, b) => a.ID - b.ID,
},
{
title: 'Created Date',
dataIndex: 'Created Date',
key: 'Created Date',
},
{
title: 'No. Requests',
dataIndex: 'No. Requests',
key: 'No. Requests',
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
},
{
title: 'Purchase Date Acc',
dataIndex: 'Purchase Date Acc',
key: 'Purchase Date Acc',
render: (_, record) => {
return (
record['Purchase Date Acc'] &&
Number(record['Purchase Date Acc']).toFixed(2)
);
},
},
{
title: 'Retailer Acc',
dataIndex: 'Retailer Acc',
key: 'Retailer Acc',
render: (_, record) => {
return (
record['Retailer Acc'] && Number(record['Retailer Acc']).toFixed(2)
);
},
},
{
title: 'IMEI Acc',
dataIndex: 'IMEI Acc',
key: 'IMEI Acc',
render: (_, record) => {
return record['IMEI Acc'] && Number(record['IMEI Acc']).toFixed(2);
},
},
{
title: 'Avg Accuracy',
dataIndex: 'Avg Accuracy',
key: 'Avg Accuracy',
render: (_, record) => {
return (
record['Avg Accuracy'] && Number(record['Avg Accuracy']).toFixed(2)
);
},
},
{
title: 'Avg Client Request Time',
dataIndex: 'Avg. Client Request Time',
key: 'Avg. Client Request Time',
render: (_, record) => {
return (
record['Avg Client Request Time'] &&
Number(record['Avg Client Request Time']).toFixed(2)
);
},
},
{
title: 'Avg. OCR Processing Time',
dataIndex: 'Avg. OCR Processing Time',
key: 'Avg. OCR Processing Time',
render: (_, record) => {
return (
record['Avg. OCR Processing Time'] &&
Number(record['Avg. OCR Processing Time']).toFixed(2)
);
},
},
{
title: 'Actions',
dataIndex: 'actions',
key: 'actions',
width: 200,
render: (_, record) => {
return (
<div style={{ flexDirection: 'row' }}>
<Button
onClick={() => {
navigate(`/reports/${record.report_id}/detail`);
}}
style={{ marginRight: 10 }}
>
Detail
</Button>
<Button>Download</Button>
</div>
);
},
},
];
return (
<>
<Table
loading={isLoading}
columns={columns}
dataSource={(report_data && report_data?.report_detail) || []}
bordered
size='small'
pagination={{
current: pagination.page,
pageSize: pagination.page_size,
total: report_data?.count || 0,
onChange: (page, pageSize) => {
setPagination({ page, page_size: pageSize || 10 });
},
showSizeChanger: false,
}}
/>
</>
);
};
export default ReportTable;

View File

@ -0,0 +1,110 @@
import { DownOutlined } from '@ant-design/icons';
import type { TableColumnsType } from 'antd';
import { Badge, Dropdown, Space, Table } from 'antd';
import React from 'react';
interface DataType {
key: React.Key;
name: string;
platform: string;
version: string;
upgradeNum: number;
creator: string;
createdAt: string;
}
interface ExpandedDataType {
key: React.Key;
date: string;
name: string;
upgradeNum: string;
}
const items = [
{ key: '1', label: 'Action 1' },
{ key: '2', label: 'Action 2' },
];
const TestTable: React.FC = () => {
const expandedRowRender = () => {
const columns: TableColumnsType<ExpandedDataType> = [
{ title: 'Date', dataIndex: 'date', key: 'date' },
{ title: 'Name', dataIndex: 'name', key: 'name' },
{
title: 'Status',
key: 'state',
render: () => <Badge status='success' text='Finished' />,
},
{ title: 'Upgrade Status', dataIndex: 'upgradeNum', key: 'upgradeNum' },
{
title: 'Action',
dataIndex: 'operation',
key: 'operation',
render: () => (
<Space size='middle'>
<a>Pause</a>
<a>Stop</a>
<Dropdown menu={{ items }}>
<a>
More <DownOutlined />
</a>
</Dropdown>
</Space>
),
},
];
const data = [];
for (let i = 0; i < 3; ++i) {
data.push({
key: i.toString(),
date: '2014-12-24 23:12:00',
name: 'This is production name',
upgradeNum: 'Upgraded: 56',
});
}
return (
<Table
columns={columns}
dataSource={data}
pagination={false}
showHeader={false}
/>
);
};
const columns: TableColumnsType<DataType> = [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Platform', dataIndex: 'platform', key: 'platform' },
{ title: 'Version', dataIndex: 'version', key: 'version' },
{ title: 'Upgraded', dataIndex: 'upgradeNum', key: 'upgradeNum' },
{ title: 'Creator', dataIndex: 'creator', key: 'creator' },
{ title: 'Date', dataIndex: 'createdAt', key: 'createdAt' },
{ title: 'Action', key: 'operation', render: () => <a>Publish</a> },
];
const data: DataType[] = [];
for (let i = 0; i < 3; ++i) {
data.push({
key: i.toString(),
name: 'Screen',
platform: 'iOS',
version: '10.3.4.5654',
upgradeNum: 500,
creator: 'Jack',
createdAt: '2014-12-24 23:12:00',
});
}
return (
<>
<Table
columns={columns}
expandable={{ expandedRowRender, defaultExpandedRowKeys: ['0'] }}
dataSource={data}
/>
</>
);
};
export default TestTable;

View File

@ -1,22 +0,0 @@
import { t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Button, Result } from 'antd';
import { useNavigate } from 'react-router-dom';
export default function () {
const { i18n } = useLingui();
const navigate = useNavigate();
return (
<Result
status="404"
title="401"
subTitle={t(i18n)`Unauthenticated.`}
extra={
<Button type="primary" onClick={() => navigate('/invoice')}>
<Trans>Try Again</Trans>
</Button>
}
/>
);
}

View File

@ -1,15 +0,0 @@
import { t } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Result } from 'antd';
export default function () {
const { i18n } = useLingui();
return (
<Result
status="500"
title="500"
subTitle={t(i18n)`Sorry, something went wrong.`}
/>
);
}

View File

@ -0,0 +1,21 @@
import { t } from '@lingui/macro';
import { Button, Result } from 'antd';
import { useNavigate } from 'react-router-dom';
export const ForbiddenResult = () => {
const navigate = useNavigate();
return (
<Result
status='403'
title='403'
subTitle={t`Sorry, you are not authorized to access this page.`}
extra={
<Button
onClick={() => navigate('/dashboard')}
type='primary'
>{t`Back to Dashboard`}</Button>
}
/>
);
};

View File

@ -0,0 +1,2 @@
export * from './forbidden';
export * from './not-found';

View File

@ -0,0 +1,20 @@
import { t } from '@lingui/macro';
import { Button, Result } from 'antd';
import { useNavigate } from 'react-router-dom';
export const NotFound = () => {
const navigate = useNavigate();
return (
<Result
status='404'
title='404'
subTitle={t`Sorry, the page you visited does not exist.`}
extra={
<Button
type='primary'
onClick={() => navigate('/dashboard')}
>{t`Back to Dashboard`}</Button>
}
/>
);
};

View File

@ -0,0 +1,16 @@
import { Spin } from 'antd';
export function GlobalSpin() {
return (
<Spin
size='large'
tip='Loading ...'
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
);
}

View File

@ -1,6 +0,0 @@
export const testingUser = {
id: 1,
maxTemplate: 3,
maxBoundingBox: 20,
uniqueComma: 'W5@X8#',
};

View File

@ -1,6 +1,15 @@
export const DIVERGE_FORM_ICON_COLOR = 'rgba(0, 0, 0, 0.25)';
export const DEFAULT_PAGE = 1; export const DEFAULT_PAGE = 1;
export const DEFAULT_PAGE_SIZE = 10; export const DEFAULT_PAGE_SIZE = 10;
export const TOKEN_KEY = 'cope2n-token'; export const DATE_FORMAT = 'YYYY-MM-DD';
export const LOCALE_KEY = 'cope2n-locale'; export const TIME_FORMAT = 'HH:mm';
export const DEFAULT_REFRESH_INTERVAL = 2_000; export const DATE_TIME_FORMAT = `${DATE_FORMAT} ${TIME_FORMAT}`;
export const PRECISION = 3; export const GET_ALL_SIZE = 10000; /* This is a tricky number to get all items */
export const POLLING_INTERVAL_MS = 5 * 1000;
export const DEBOUNCE_SEARCH_MS = 0.2 * 1000;
export const MIN_CHARACTERS_TO_SEARCH = 2;
export const ANT_PREFIX_CLASS = 'sbt';
export const FALLBACK_IP_ADDRESS = '107.120.133.22';
export const FALLBACK_IMAGE_BASE64 =
'data:image/png;base64,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';

View File

@ -1,122 +0,0 @@
import { t } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { notification, Upload, UploadFile, UploadProps } from 'antd';
import { RcFile } from 'antd/es/upload';
import React from 'react';
import styled from 'styled-components';
import {
IMAGE_SIZE_LIMIT,
isFileUnderLimit,
isImageFile,
isPDFFile,
pdf2Images,
} from '../utils/file';
import { bytesToMegabytes } from '../utils/storage';
export function useAntdLocalFiles(uploadProps: UploadProps) {
const fileUriRef = React.useRef('');
const [selectedFile, setSelectedFile] = React.useState<UploadFile | null>(
null,
);
const [files, setFiles] = React.useState<UploadFile[]>([]);
const { i18n } = useLingui();
const fileValidation = (file) => {
if (!isImageFile(file) && !isPDFFile(file)) {
notification.error({
message: t(i18n)`Upload file validation`,
description: t(
i18n,
)`You are not allowed to upload file other than images (.jpg, .png, .jpeg).`,
});
return false;
}
if (!isFileUnderLimit(file)) {
notification.error({
message: t(i18n)`Upload file validation`,
description: t(
i18n,
)`You are not allowed to upload file bigger than ${bytesToMegabytes(
IMAGE_SIZE_LIMIT,
)}MB.`,
});
return false;
}
return true;
};
const onChange: UploadProps['onChange'] = React.useCallback(
async ({ fileList, file }) => {
for (file of fileList) {
if (isPDFFile(file.originFileObj)) {
const imageFiles = (await pdf2Images(file.originFileObj)) as RcFile[];
file.originFileObj = imageFiles.shift();
}
}
const isValidFile = fileValidation(file);
if (isValidFile) {
if (uploadProps.maxCount === 1 && fileList.length) {
URL.revokeObjectURL(fileUriRef.current);
setSelectedFile(fileList[0]);
fileUriRef.current = URL.createObjectURL(fileList[0].originFileObj);
}
setFiles(fileList);
}
},
[uploadProps.maxCount],
);
const onPreview: UploadProps['onPreview'] = React.useCallback((file) => {
URL.revokeObjectURL(fileUriRef.current);
fileUriRef.current = URL.createObjectURL(file.originFileObj);
setSelectedFile(file);
}, []);
const onRemove: UploadProps['onRemove'] = React.useCallback(
(file) => {
if (file.uid === selectedFile?.uid) {
URL.revokeObjectURL(fileUriRef.current);
fileUriRef.current = '';
setSelectedFile(null);
}
setFiles((prevFiles) => prevFiles.filter((f) => f.uid !== file.uid));
},
[selectedFile],
);
const reset = React.useCallback(() => {
setFiles([]);
setSelectedFile(null);
fileUriRef.current = '';
}, []);
const antdUploadProps: UploadProps = {
...uploadProps,
onChange,
onPreview,
onRemove,
fileList: files,
beforeUpload() {
return false;
},
};
return {
selectedFileUri: fileUriRef.current,
selectedFile,
antdUploadProps,
fileUriRef,
reset,
};
}
export const StyledUpload = styled(Upload)`
& .ant-upload-list-item-actions > a {
display: none;
}
`;

View File

@ -1,9 +1,34 @@
import { useToken } from './useToken'; import { useQueryClient } from '@tanstack/react-query';
import { useLogIn, useLogout } from 'queries';
import { LogInResponse } from 'request/user';
import { useGlobalState } from 'utils/hooks';
export function useAuth() { export function useAuth() {
const token = useToken(); const queryClient = useQueryClient();
const { data: isAuthenticated } = useGlobalState(
['isAuthenticated'],
Boolean(localStorage.getItem('sbt-token')),
);
const logInMutation = useLogIn({
onSuccess(data: LogInResponse) {
localStorage.setItem('sbt-token', JSON.stringify(data.token));
localStorage.setItem('user-name', JSON.stringify(data.user_name));
window.location.reload();
},
});
const logOutMutation = useLogout({
onSuccess() {
localStorage.removeItem('sbt-token');
queryClient.clear();
window.location.reload();
},
});
return { return {
isAuthenticated: Boolean(token), isAuthenticated,
isAuthenticating: logInMutation.isLoading,
logIn: logInMutation.mutateAsync,
logOut: logOutMutation.mutateAsync,
}; };
} }

View File

@ -1,40 +0,0 @@
import React from 'react';
const DEFAULT_AUTO_REFRESH_TIME = 10_000;
export function useAutoRefreshFlag({
initialFlag = false,
autoStopTimeMs = DEFAULT_AUTO_REFRESH_TIME,
onStopByTimeout,
}: {
initialFlag?: boolean;
autoStopTimeMs?: number;
onStopByTimeout?(): void;
} = {}) {
const callbackRef = React.useRef(onStopByTimeout);
const timeoutRef = React.useRef(0);
const [shouldAutoRefresh, setShouldAutoRefresh] = React.useState(initialFlag);
return React.useMemo(
() => ({
timeOutId: timeoutRef.current,
isRefreshing: shouldAutoRefresh,
trigger: () => {
setShouldAutoRefresh(true);
timeoutRef.current = window.setTimeout(() => {
if (!shouldAutoRefresh) {
setShouldAutoRefresh(false);
if (callbackRef.current) {
callbackRef.current();
}
}
}, autoStopTimeMs);
},
stop: () => {
setShouldAutoRefresh(false);
clearTimeout(timeoutRef.current);
},
}),
[shouldAutoRefresh, autoStopTimeMs],
);
}

View File

@ -1,31 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import React from 'react';
export const useGlobalState = <T>(key: any[], initialData: T) => {
const queryClient = useQueryClient();
const stateQueries = useQuery<T>(
key,
() => Promise.reject('This thing never happens.'),
{
initialData,
staleTime: Infinity,
cacheTime: Infinity,
},
);
const setData = React.useCallback(
(param: React.SetStateAction<T>) => {
const oldState = queryClient.getQueryData<T>(key);
queryClient.setQueryData(
key,
typeof param === 'function' ? (param as Function)(oldState) : param,
);
},
[queryClient, key],
);
return {
setData,
data: stateQueries.data,
};
};

View File

@ -1,15 +0,0 @@
import React from 'react';
export function useInitialCardHeight(
cardRef: React.MutableRefObject<HTMLDivElement>,
) {
const heightRef = React.useRef<number>(0);
React.useEffect(() => {
if (heightRef.current <= 0 && cardRef.current) {
heightRef.current = cardRef.current.clientHeight;
}
});
return heightRef.current;
}

Some files were not shown because too many files have changed in this diff Show More