Web client refinement (#16)
* fix(files/handler): add base64 decode for content * fix(singleuser): pick user name from jwt token and encode content * fix(singleuser): add public path check, abstract user info from token * fix(singleuser): update singleuser client * fix(server): fix test and enable auth by default * feat(client/web): add web client * fix(client/web): refine css styles * fix(client/web): refine styles * fix(client/web): refine styles, add test and fix bugs * test(client/web): add web client tests * fix(client/web): refactor client interface and enhance the robustness * chore(client/web): ignore js bundles * test(files): call sync before check Co-authored-by: Jia He <jiah@nvidia.com>
This commit is contained in:
parent
0265baf1b1
commit
ba6a5373d1
53 changed files with 9192 additions and 158 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -6,3 +6,5 @@
|
|||
**/dist
|
||||
**/vendor
|
||||
**/yarn-error
|
||||
**/public/static/*/*.js
|
||||
**/public/static/**/*.js
|
|
@ -1,13 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script src="dist/assets.bundle.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script src="dist/api_test.bundle.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
BIN
public/ggb.jpg
BIN
public/ggb.jpg
Binary file not shown.
Before Width: | Height: | Size: 630 KiB |
|
@ -1,16 +1,94 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||
<link rel="stylesheet" type="text/css" href="style.css" />
|
||||
<script src="dist/assets.bundle.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="dist/admin.bundle.js"></script>
|
||||
</body>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Quickshare</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="initial-scale=1.0, maximum-scale=1, minimum-scale=1, user-scalable=no,uc-fitscreen=yes"
|
||||
/>
|
||||
<meta class="chrome-color" name="theme-color" content="black" />
|
||||
<script src="/static/js/react.development.js?v=16.8.6"></script>
|
||||
<script src="/static/js/react-dom.development.js?v=16.8.6"></script>
|
||||
<script src="/static/js/immutable.min.js?v=4.0.0-rc.12"></script>
|
||||
<!-- <link
|
||||
rel="apple-touch-icon"
|
||||
sizes="57x57"
|
||||
href="/static/fav/apple-icon-57x57.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="60x60"
|
||||
href="/static/fav/apple-icon-60x60.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="72x72"
|
||||
href="/static/fav/apple-icon-72x72.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="76x76"
|
||||
href="/static/fav/apple-icon-76x76.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="114x114"
|
||||
href="/static/fav/apple-icon-114x114.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="120x120"
|
||||
href="/static/fav/apple-icon-120x120.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="144x144"
|
||||
href="/static/fav/apple-icon-144x144.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="152x152"
|
||||
href="/static/fav/apple-icon-152x152.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/static/fav/apple-icon-180x180.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="192x192"
|
||||
href="/static/fav/android-icon-192x192.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/static/fav/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="96x96"
|
||||
href="/static/fav/favicon-96x96.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/static/fav/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/static/fav/manifest.json" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta
|
||||
name="msapplication-TileImage"
|
||||
content="/static/fav/ms-icon-144x144.png"
|
||||
/> -->
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="content"><div id="mount"></div></div>
|
||||
<script src="static/default-vendors-node_modules_axios_index_js-node_modules_css-loader_dist_runtime_api_js-node_-e9ca3b.bundle.js?910792307d026e16bec7"></script><script src="static/main.bundle.js?910792307d026e16bec7"></script></body>
|
||||
</html>
|
Binary file not shown.
Before Width: | Height: | Size: 16 KiB |
BIN
public/static/img/prism.png
Normal file
BIN
public/static/img/prism.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.5 KiB |
BIN
public/static/img/textured_paper.png
Normal file
BIN
public/static/img/textured_paper.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 131 KiB |
|
@ -1,68 +0,0 @@
|
|||
html {
|
||||
background: url("ggb.jpg") no-repeat left center fixed;
|
||||
background-size: auto 100%;
|
||||
/* background-image: url("squared_metal.png"); repeat fixed*/
|
||||
background-color: black;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
color: #333;
|
||||
font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
a {
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.anm-rotate {
|
||||
animation: rotate 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
20% {
|
||||
transform: rotate(72deg);
|
||||
}
|
||||
40% {
|
||||
transform: rotate(144deg);
|
||||
}
|
||||
60% {
|
||||
transform: rotate(216deg);
|
||||
}
|
||||
80% {
|
||||
transform: rotate(288deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.anm-lighter:hover {
|
||||
animation: lighter 0.5 1;
|
||||
}
|
||||
|
||||
@keyframes lighter {
|
||||
0% {
|
||||
opacity: 100%;
|
||||
}
|
||||
50% {
|
||||
opacity: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background: #2ecc71;
|
||||
color: #fff;
|
||||
}
|
||||
::selection {
|
||||
background: #2ecc71;
|
||||
color: #fff;
|
||||
}
|
|
@ -34,19 +34,15 @@ func (cl *SingleUserClient) Login(user, pwd string) (*http.Response, string, []e
|
|||
End()
|
||||
}
|
||||
|
||||
func (cl *SingleUserClient) Logout(user string, token *http.Cookie) (*http.Response, string, []error) {
|
||||
func (cl *SingleUserClient) Logout(token *http.Cookie) (*http.Response, string, []error) {
|
||||
return cl.r.Post(cl.url("/v1/users/logout")).
|
||||
Send(su.LogoutReq{
|
||||
User: user,
|
||||
}).
|
||||
AddCookie(token).
|
||||
End()
|
||||
}
|
||||
|
||||
func (cl *SingleUserClient) SetPwd(user, oldPwd, newPwd string, token *http.Cookie) (*http.Response, string, []error) {
|
||||
func (cl *SingleUserClient) SetPwd(oldPwd, newPwd string, token *http.Cookie) (*http.Response, string, []error) {
|
||||
return cl.r.Patch(cl.url("/v1/users/pwd")).
|
||||
Send(su.SetPwdReq{
|
||||
User: user,
|
||||
OldPwd: oldPwd,
|
||||
NewPwd: newPwd,
|
||||
}).
|
||||
|
|
8
src/client/web/.babelrc
Normal file
8
src/client/web/.babelrc
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"presets": ["@babel/env", "@babel/react"],
|
||||
"env": {
|
||||
"test": {
|
||||
"plugins": ["@babel/plugin-transform-runtime"]
|
||||
}
|
||||
}
|
||||
}
|
3
src/client/web/.gitignore
vendored
Normal file
3
src/client/web/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
**/node_modules/**
|
||||
**/yarn-error.log
|
||||
**/bundle.js
|
94
src/client/web/build/template/index.template.dev.html
Normal file
94
src/client/web/build/template/index.template.dev.html
Normal file
|
@ -0,0 +1,94 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Quickshare</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="initial-scale=1.0, maximum-scale=1, minimum-scale=1, user-scalable=no,uc-fitscreen=yes"
|
||||
/>
|
||||
<meta class="chrome-color" name="theme-color" content="black" />
|
||||
<script src="/static/js/react.development.js?v=16.8.6"></script>
|
||||
<script src="/static/js/react-dom.development.js?v=16.8.6"></script>
|
||||
<script src="/static/js/immutable.min.js?v=4.0.0-rc.12"></script>
|
||||
<!-- <link
|
||||
rel="apple-touch-icon"
|
||||
sizes="57x57"
|
||||
href="/static/fav/apple-icon-57x57.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="60x60"
|
||||
href="/static/fav/apple-icon-60x60.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="72x72"
|
||||
href="/static/fav/apple-icon-72x72.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="76x76"
|
||||
href="/static/fav/apple-icon-76x76.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="114x114"
|
||||
href="/static/fav/apple-icon-114x114.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="120x120"
|
||||
href="/static/fav/apple-icon-120x120.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="144x144"
|
||||
href="/static/fav/apple-icon-144x144.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="152x152"
|
||||
href="/static/fav/apple-icon-152x152.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/static/fav/apple-icon-180x180.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="192x192"
|
||||
href="/static/fav/android-icon-192x192.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/static/fav/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="96x96"
|
||||
href="/static/fav/favicon-96x96.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/static/fav/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/static/fav/manifest.json" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta
|
||||
name="msapplication-TileImage"
|
||||
content="/static/fav/ms-icon-144x144.png"
|
||||
/> -->
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="content"><div id="mount"></div></div>
|
||||
</body>
|
||||
</html>
|
94
src/client/web/build/template/index.template.html
Normal file
94
src/client/web/build/template/index.template.html
Normal file
|
@ -0,0 +1,94 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Quickshare</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="initial-scale=1.0, maximum-scale=1, minimum-scale=1, user-scalable=no,uc-fitscreen=yes"
|
||||
/>
|
||||
<meta class="chrome-color" name="theme-color" content="black" />
|
||||
<!-- <script src="/static/js/react.production.min.js?v=16.8.6"></script>
|
||||
<script src="/static/js/react-dom.production.min.js?v=16.8.6"></script>
|
||||
<script src="/static/js/immutable.min.js?v=4.0.0-rc.12"></script>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="57x57"
|
||||
href="/static/fav/apple-icon-57x57.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="60x60"
|
||||
href="/static/fav/apple-icon-60x60.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="72x72"
|
||||
href="/static/fav/apple-icon-72x72.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="76x76"
|
||||
href="/static/fav/apple-icon-76x76.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="114x114"
|
||||
href="/static/fav/apple-icon-114x114.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="120x120"
|
||||
href="/static/fav/apple-icon-120x120.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="144x144"
|
||||
href="/static/fav/apple-icon-144x144.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="152x152"
|
||||
href="/static/fav/apple-icon-152x152.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/static/fav/apple-icon-180x180.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="192x192"
|
||||
href="/static/fav/android-icon-192x192.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/static/fav/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="96x96"
|
||||
href="/static/fav/favicon-96x96.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/static/fav/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/static/fav/manifest.json" /> -->
|
||||
<!-- <meta name="msapplication-TileColor" content="#ffffff" /> -->
|
||||
<!-- <meta
|
||||
name="msapplication-TileImage"
|
||||
content="/static/fav/ms-icon-144x144.png"
|
||||
/> -->
|
||||
<!-- <meta name="theme-color" content="#ffffff" /> -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="content"><div id="mount"></div></div>
|
||||
</body>
|
||||
</html>
|
83
src/client/web/package.json
Normal file
83
src/client/web/package.json
Normal file
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"name": "web",
|
||||
"version": "0.2.0",
|
||||
"description": "web client for quickshare",
|
||||
"main": "",
|
||||
"scripts": {
|
||||
"build": "webpack --config webpack.app.prod.js",
|
||||
"build:watch": "webpack --config webpack.app.prod.js --watch",
|
||||
"build:dev": "webpack --config webpack.app.dev.js --watch",
|
||||
"build:admin": "webpack --config webpack.admin.prod.js",
|
||||
"build:admin:watch": "webpack --config webpack.admin.prod.js --watch",
|
||||
"build:admin:dev": "webpack --config webpack.admin.dev.js --watch",
|
||||
"build:task": "webpack --config webpack.task.prod.js",
|
||||
"build:task:watch": "webpack --config webpack.task.prod.js --watch",
|
||||
"build:task:dev": "webpack --config webpack.task.dev.js --watch",
|
||||
"e2e": "jest -c jest.e2e.config.js",
|
||||
"e2e:watch": "jest --watch -c jest.e2e.config.js",
|
||||
"test": "jest test",
|
||||
"test:watch": "jest test --watch",
|
||||
"copy": "cp -r ../../static ../../../dockers/nginx/"
|
||||
},
|
||||
"author": "hexxa",
|
||||
"license": "LGPL-3.0",
|
||||
"devDependencies": {
|
||||
"@babel/plugin-transform-runtime": "^7.4.4",
|
||||
"@babel/preset-env": "^7.4.4",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@types/assert": "^1.4.2",
|
||||
"@types/deep-diff": "^1.0.0",
|
||||
"@types/jest": "^24.0.12",
|
||||
"assert": "^2.0.0",
|
||||
"css-loader": "^2.1.1",
|
||||
"deep-diff": "^1.0.2",
|
||||
"html-webpack-plugin": "^4.0.0-beta.5",
|
||||
"jest": "^24.8.0",
|
||||
"source-map-loader": "^0.2.4",
|
||||
"style-loader": "^0.23.1",
|
||||
"terser-webpack-plugin": "^1.3.0",
|
||||
"ts-jest": "^24.0.2",
|
||||
"ts-loader": "^6.0.0",
|
||||
"ts-node": "^8.2.0",
|
||||
"tslint": "^5.16.0",
|
||||
"typescript": "^3.4.3",
|
||||
"uglifyjs-webpack-plugin": "^2.1.3",
|
||||
"webpack": "^5.0.0-rc.6",
|
||||
"webpack-bundle-analyzer": "^3.3.2",
|
||||
"webpack-cli": "^3.3.0",
|
||||
"webpack-merge": "^4.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/axios": "^0.14.0",
|
||||
"@types/immutable": "^3.8.7",
|
||||
"@types/react": "^16.8.13",
|
||||
"@types/react-copy-to-clipboard": "^4.2.6",
|
||||
"@types/react-dom": "^16.8.4",
|
||||
"@types/react-svg": "^5.0.0",
|
||||
"@types/throttle-debounce": "^1.1.1",
|
||||
"axios": "^0.18.0",
|
||||
"filesize": "^6.1.0",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"react": "^16.8.6",
|
||||
"react-copy-to-clipboard": "^5.0.1",
|
||||
"react-dom": "^16.8.6",
|
||||
"react-svg": "^8.0.6",
|
||||
"throttle-debounce": "^2.1.0"
|
||||
},
|
||||
"jest": {
|
||||
"testMatch": [
|
||||
"**/src/**/__test__/**/*.test.ts",
|
||||
"**/src/**/__test__/**/*.test.tsx"
|
||||
],
|
||||
"transform": {
|
||||
"\\.(ts|tsx)$": "ts-jest"
|
||||
},
|
||||
"verbose": true,
|
||||
"moduleFileExtensions": [
|
||||
"ts",
|
||||
"tsx",
|
||||
"js"
|
||||
]
|
||||
},
|
||||
"autoBump": {}
|
||||
}
|
14
src/client/web/src/app.tsx
Normal file
14
src/client/web/src/app.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
|
||||
import { StateMgr } from "./components/state_mgr";
|
||||
|
||||
import "./theme/reset.css";
|
||||
import "./theme/white.css";
|
||||
// TODO: it fails in jest preprocessor now
|
||||
import "./theme/style.css";
|
||||
import "./theme/desktop.css";
|
||||
import "./theme/color.css";
|
||||
|
||||
|
||||
ReactDOM.render(<StateMgr />, document.getElementById("mount"));
|
104
src/client/web/src/client/files.ts
Normal file
104
src/client/web/src/client/files.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
import {
|
||||
BaseClient,
|
||||
Response,
|
||||
UploadStatusResp,
|
||||
ListResp,
|
||||
} from "./";
|
||||
|
||||
const filePathQuery = "fp";
|
||||
const listDirQuery = "dp";
|
||||
// TODO: get timeout from server
|
||||
|
||||
export class FilesClient extends BaseClient {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
}
|
||||
|
||||
create = (filePath: string, fileSize: number): Promise<Response> => {
|
||||
return this.do({
|
||||
method: "post",
|
||||
url: `${this.url}/v1/fs/files`,
|
||||
data: {
|
||||
path: filePath,
|
||||
fileSize: fileSize,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
delete = (filePath: string): Promise<Response> => {
|
||||
return this.do({
|
||||
method: "delete",
|
||||
url: `${this.url}/v1/fs/files`,
|
||||
params: {
|
||||
[filePathQuery]: filePath,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
metadata = (filePath: string): Promise<Response> => {
|
||||
return this.do({
|
||||
method: "get",
|
||||
url: `${this.url}/v1/fs/metadata`,
|
||||
params: {
|
||||
[filePathQuery]: filePath,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
mkdir = (dirpath: string): Promise<Response> => {
|
||||
return this.do({
|
||||
method: "post",
|
||||
url: `${this.url}/v1/fs/dirs`,
|
||||
data: {
|
||||
path: dirpath,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
move = (oldPath: string, newPath: string): Promise<Response> => {
|
||||
return this.do({
|
||||
method: "patch",
|
||||
url: `${this.url}/v1/fs/files/move`,
|
||||
data: {
|
||||
oldPath,
|
||||
newPath,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
uploadChunk = (
|
||||
filePath: string,
|
||||
content: string | ArrayBuffer,
|
||||
offset: number
|
||||
): Promise<Response<UploadStatusResp>> => {
|
||||
return this.do({
|
||||
method: "patch",
|
||||
url: `${this.url}/v1/fs/files/chunks`,
|
||||
data: {
|
||||
path: filePath,
|
||||
content,
|
||||
offset,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
uploadStatus = (filePath: string): Promise<Response<UploadStatusResp>> => {
|
||||
return this.do({
|
||||
method: "get",
|
||||
url: `${this.url}/v1/fs/files/chunks`,
|
||||
params: {
|
||||
[filePathQuery]: filePath,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
list = (dirPath: string): Promise<Response<ListResp>> => {
|
||||
return this.do({
|
||||
method: "get",
|
||||
url: `${this.url}/v1/fs/dirs`,
|
||||
params: {
|
||||
[listDirQuery]: dirPath,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
82
src/client/web/src/client/files_mock.ts
Normal file
82
src/client/web/src/client/files_mock.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
|
||||
import {MetadataResp, UploadStatusResp, ListResp} from "./";
|
||||
|
||||
export class FilesClient {
|
||||
private url: string;
|
||||
private createMockResp: number;
|
||||
private deleteMockResp: number;
|
||||
private metadataMockResp: MetadataResp | null;
|
||||
private mkdirMockResp: number | null;
|
||||
private moveMockResp: number;
|
||||
private uploadChunkMockResp: UploadStatusResp | null;
|
||||
private uploadStatusMockResp: UploadStatusResp | null;
|
||||
private listMockResp: ListResp | null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
createMock = (resp: number) => {
|
||||
this.createMockResp = resp;
|
||||
}
|
||||
deleteMock = (resp: number) => {
|
||||
this.deleteMockResp = resp;
|
||||
}
|
||||
metadataMock = (resp: MetadataResp | null) => {
|
||||
this.metadataMockResp = resp;
|
||||
}
|
||||
mkdirMock = (resp: number | null) => {
|
||||
this.mkdirMockResp = resp;
|
||||
}
|
||||
moveMock = (resp: number) => {
|
||||
this.moveMockResp = resp;
|
||||
}
|
||||
uploadChunkMock = (resp: UploadStatusResp | null) => {
|
||||
this.uploadChunkMockResp = resp;
|
||||
}
|
||||
|
||||
uploadStatusMock = (resp: UploadStatusResp | null) => {
|
||||
this.uploadStatusMockResp = resp;
|
||||
}
|
||||
|
||||
listMock = (resp: ListResp | null) => {
|
||||
this.listMockResp = resp;
|
||||
}
|
||||
|
||||
async create(filePath: string, fileSize: number): Promise<number> {
|
||||
return this.createMockResp;
|
||||
}
|
||||
|
||||
async delete(filePath: string): Promise<number> {
|
||||
return this.deleteMockResp;
|
||||
}
|
||||
|
||||
async metadata(filePath: string): Promise<MetadataResp | null> {
|
||||
return this.metadataMockResp;
|
||||
}
|
||||
|
||||
async mkdir(dirpath: string): Promise<number | null> {
|
||||
return this.mkdirMockResp;
|
||||
}
|
||||
|
||||
async move(oldPath: string, newPath: string): Promise<number> {
|
||||
return this.moveMockResp;
|
||||
}
|
||||
|
||||
async uploadChunk(
|
||||
filePath: string,
|
||||
content: string | ArrayBuffer,
|
||||
offset: number
|
||||
): Promise<UploadStatusResp | null> {
|
||||
return this.uploadChunkMockResp;
|
||||
}
|
||||
|
||||
async uploadStatus(filePath: string): Promise<UploadStatusResp | null> {
|
||||
return this.uploadStatusMockResp;
|
||||
}
|
||||
|
||||
async list(dirPath: string): Promise<ListResp | null> {
|
||||
return this.listMockResp;
|
||||
}
|
||||
}
|
110
src/client/web/src/client/index.ts
Normal file
110
src/client/web/src/client/index.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
import axios, { AxiosRequestConfig } from "axios";
|
||||
|
||||
export const defaultTimeout = 10000;
|
||||
|
||||
export interface MetadataResp {
|
||||
name: string;
|
||||
size: number;
|
||||
modTime: string;
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
export interface UploadStatusResp {
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
fileSize: number;
|
||||
uploaded: number;
|
||||
}
|
||||
|
||||
export interface ListResp {
|
||||
metadatas: MetadataResp[];
|
||||
}
|
||||
|
||||
export interface IUsersClient {
|
||||
login: (user: string, pwd: string) => Promise<Response>
|
||||
logout: () => Promise<Response>
|
||||
isAuthed: () => Promise<Response>
|
||||
setPwd: (oldPwd: string, newPwd: string) => Promise<Response>
|
||||
}
|
||||
|
||||
export interface IFilesClient {
|
||||
create: (filePath: string, fileSize: number) => Promise<Response>;
|
||||
delete: (filePath: string) => Promise<Response>;
|
||||
metadata: (filePath: string) => Promise<Response>;
|
||||
mkdir: (dirpath: string) => Promise<Response>;
|
||||
move: (oldPath: string, newPath: string) => Promise<Response>;
|
||||
uploadChunk: (
|
||||
filePath: string,
|
||||
content: string | ArrayBuffer,
|
||||
offset: number
|
||||
) => Promise<Response<UploadStatusResp>>;
|
||||
uploadStatus: (filePath: string) => Promise<Response<UploadStatusResp>>;
|
||||
list: (dirPath: string) => Promise<Response<ListResp>>;
|
||||
}
|
||||
|
||||
export interface Response<T = any> {
|
||||
status: number;
|
||||
statusText: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export const TimeoutResp: Response<any> = {
|
||||
status: 408,
|
||||
data: {},
|
||||
statusText: "Request Timeout",
|
||||
};
|
||||
|
||||
// 6xx are custom errors for expressing errors which can not be expressed by http status code
|
||||
export const EmptyBodyResp: Response<any> = {
|
||||
status: 601,
|
||||
data: {},
|
||||
statusText: "Empty Response Body",
|
||||
};
|
||||
|
||||
export const UnknownErrResp = (errMsg: string): Response<any> => {
|
||||
return {
|
||||
status: 600,
|
||||
data: {},
|
||||
statusText: errMsg,
|
||||
};
|
||||
};
|
||||
|
||||
export class BaseClient {
|
||||
protected url: string;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
async do(config: AxiosRequestConfig): Promise<Response> {
|
||||
let returned = false;
|
||||
const src = axios.CancelToken.source();
|
||||
|
||||
return new Promise((resolve: (ret: Response) => void) => {
|
||||
setTimeout(() => {
|
||||
if (!returned) {
|
||||
src.cancel("request timeout");
|
||||
// resolve(TimeoutResp);
|
||||
}
|
||||
}, defaultTimeout);
|
||||
|
||||
axios({ ...config, cancelToken: src.token })
|
||||
.then((resp) => {
|
||||
returned = true;
|
||||
resolve(resp);
|
||||
})
|
||||
.catch((e) => {
|
||||
const errMsg = e.toString();
|
||||
console.log(e);
|
||||
|
||||
if (errMsg.includes("i/o timeput")) {
|
||||
resolve(TimeoutResp);
|
||||
} else if (errMsg.includes("ERR_EMPTY")) {
|
||||
resolve(EmptyBodyResp);
|
||||
} else {
|
||||
resolve(UnknownErrResp(errMsg));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
109
src/client/web/src/client/uploader.ts
Normal file
109
src/client/web/src/client/uploader.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { List } from "immutable";
|
||||
|
||||
import { Response, UnknownErrResp, UploadStatusResp } from "./";
|
||||
import { FilesClient } from "../client/files";
|
||||
|
||||
const defaultChunkLen = 1024 * 1024 * 30; // 15MB/s
|
||||
const speedDownRatio = 0.5;
|
||||
const speedUpRatio = 1.1;
|
||||
|
||||
export class FileUploader {
|
||||
private reader = new FileReader();
|
||||
private client = new FilesClient("");
|
||||
private file: File;
|
||||
private filePath: string;
|
||||
private offset: number;
|
||||
private chunkLen: number = defaultChunkLen;
|
||||
private progressCb: (filePath: string, progress: number) => void;
|
||||
private errMsg: string | null;
|
||||
|
||||
constructor(
|
||||
file: File,
|
||||
filePath: string,
|
||||
progressCb: (filePath: string, progress: number) => void
|
||||
) {
|
||||
this.file = file;
|
||||
this.filePath = filePath;
|
||||
this.progressCb = progressCb;
|
||||
this.offset = 0;
|
||||
}
|
||||
|
||||
err = (): string | null => {
|
||||
return this.errMsg;
|
||||
}
|
||||
|
||||
start = async (): Promise<boolean> => {
|
||||
const resp = await this.client.create(this.filePath, this.file.size);
|
||||
switch (resp.status) {
|
||||
case 200:
|
||||
return await this.upload();
|
||||
default:
|
||||
this.errMsg = `failed to create ${this.filePath}: status=${resp.statusText}`;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
upload = async (): Promise<boolean> => {
|
||||
while (
|
||||
this.chunkLen > 0 &&
|
||||
this.offset >= 0 &&
|
||||
this.offset < this.file.size
|
||||
) {
|
||||
let uploadPromise = new Promise<Response<UploadStatusResp>>(
|
||||
(resolve: (resp: Response<UploadStatusResp>) => void) => {
|
||||
this.reader.onerror = (ev: ProgressEvent<FileReader>) => {
|
||||
resolve(UnknownErrResp(this.reader.error.toString()));
|
||||
};
|
||||
|
||||
this.reader.onloadend = (ev: ProgressEvent<FileReader>) => {
|
||||
const dataURL = ev.target.result as string; // readAsDataURL
|
||||
const base64Chunk = dataURL.slice(dataURL.indexOf(",") + 1);
|
||||
this.client
|
||||
.uploadChunk(this.filePath, base64Chunk, this.offset)
|
||||
.then((resp: Response<UploadStatusResp>) => {
|
||||
resolve(resp);
|
||||
})
|
||||
.catch((e) => {
|
||||
resolve(UnknownErrResp(e.toString()));
|
||||
});
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const chunkRightPos =
|
||||
this.offset + this.chunkLen > this.file.size
|
||||
? this.file.size
|
||||
: this.offset + this.chunkLen;
|
||||
const blob = this.file.slice(this.offset, chunkRightPos);
|
||||
this.reader.readAsDataURL(blob);
|
||||
|
||||
const uploadResp = await uploadPromise;
|
||||
if (uploadResp.status === 200 && uploadResp.data != null) {
|
||||
this.offset = uploadResp.data.uploaded;
|
||||
this.chunkLen = Math.ceil(this.chunkLen * speedUpRatio);
|
||||
} else {
|
||||
this.errMsg = uploadResp.statusText;
|
||||
this.chunkLen = Math.ceil(this.chunkLen * speedDownRatio);
|
||||
const uploadStatusResp = await this.client.uploadStatus(this.filePath);
|
||||
|
||||
console.log(uploadStatusResp.status);
|
||||
|
||||
if (uploadStatusResp.status === 200) {
|
||||
this.offset = uploadStatusResp.data.uploaded;
|
||||
} else if (uploadStatusResp.status === 600) {
|
||||
this.errMsg = "unknown error";
|
||||
break
|
||||
} else {
|
||||
// do nothing and retry
|
||||
}
|
||||
}
|
||||
|
||||
this.progressCb(this.filePath, Math.ceil(this.offset / this.file.size));
|
||||
}
|
||||
|
||||
if (this.chunkLen === 0) {
|
||||
this.errMsg = "network is bad, please retry later.";
|
||||
}
|
||||
return this.offset === this.file.size;
|
||||
};
|
||||
}
|
46
src/client/web/src/client/users.ts
Normal file
46
src/client/web/src/client/users.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { BaseClient, Response } from "./";
|
||||
|
||||
|
||||
export class UsersClient extends BaseClient {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
}
|
||||
|
||||
login = (user: string, pwd: string): Promise<Response> => {
|
||||
return this.do({
|
||||
method: "post",
|
||||
url: `${this.url}/v1/users/login`,
|
||||
data: {
|
||||
user,
|
||||
pwd,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// token cookie is set by browser
|
||||
logout = (): Promise<Response> => {
|
||||
return this.do({
|
||||
method: "post",
|
||||
url: `${this.url}/v1/users/logout`,
|
||||
});
|
||||
}
|
||||
|
||||
isAuthed = (): Promise<Response> => {
|
||||
return this.do({
|
||||
method: "get",
|
||||
url: `${this.url}/v1/users/isauthed`,
|
||||
});
|
||||
}
|
||||
|
||||
// token cookie is set by browser
|
||||
setPwd = (oldPwd: string, newPwd: string): Promise<Response> => {
|
||||
return this.do({
|
||||
method: "patch",
|
||||
url: `${this.url}/v1/users/pwd`,
|
||||
data: {
|
||||
oldPwd,
|
||||
newPwd,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
45
src/client/web/src/client/users_mock.ts
Normal file
45
src/client/web/src/client/users_mock.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
// import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
|
||||
// TODO: replace this with jest mocks
|
||||
export class MockUsersClient {
|
||||
private url: string;
|
||||
private loginResp: number;
|
||||
private logoutResp: number;
|
||||
private isAuthedResp: number;
|
||||
private setPwdResp: number;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
mockLoginResp(status: number) {
|
||||
this.loginResp = status;
|
||||
}
|
||||
mocklogoutResp(status: number) {
|
||||
this.logoutResp = status;
|
||||
}
|
||||
mockisAuthedResp(status: number) {
|
||||
this.isAuthedResp = status;
|
||||
}
|
||||
mocksetPwdResp(status: number) {
|
||||
this.setPwdResp = status;
|
||||
}
|
||||
|
||||
async login(user: string, pwd: string): Promise<number> {
|
||||
return this.loginResp
|
||||
}
|
||||
|
||||
// token cookie is set by browser
|
||||
async logout(): Promise<number> {
|
||||
return this.logoutResp
|
||||
}
|
||||
|
||||
async isAuthed(): Promise<number> {
|
||||
return this.isAuthedResp
|
||||
}
|
||||
|
||||
// token cookie is set by browser
|
||||
async setPwd(oldPwd: string, newPwd: string): Promise<number> {
|
||||
return this.setPwdResp
|
||||
}
|
||||
}
|
7
src/client/web/src/common.ts
Normal file
7
src/client/web/src/common.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export const range = (start: number, end: number): Array<number> => {
|
||||
let array = new Array(0);
|
||||
for (let i = start; i <= end; i++) {
|
||||
array.push(i);
|
||||
}
|
||||
return array;
|
||||
};
|
32
src/client/web/src/components/__test__/auth_pane.test.tsx
Normal file
32
src/client/web/src/components/__test__/auth_pane.test.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { init } from "../core_state";
|
||||
import { Updater } from "../auth_pane";
|
||||
import { MockUsersClient } from "../../client/users_mock";
|
||||
|
||||
describe("AuthPane", () => {
|
||||
test("Updater: initIsAuthed", () => {
|
||||
const tests = [
|
||||
{
|
||||
loginStatus: 200,
|
||||
isAuthed: true,
|
||||
},
|
||||
{
|
||||
loginStatus: 500,
|
||||
isAuthed: false,
|
||||
},
|
||||
];
|
||||
|
||||
const client = new MockUsersClient("foobarurl");
|
||||
Updater.setClient(client);
|
||||
const coreState = init();
|
||||
|
||||
tests.forEach(async (tc) => {
|
||||
client.mockisAuthedResp(tc.loginStatus);
|
||||
await Updater.initIsAuthed().then(() => {
|
||||
const newState = Updater.setAuthPane(coreState);
|
||||
expect(newState.panel.authPane.authed).toEqual(tc.isAuthed);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
10
src/client/web/src/components/__test__/browser.test.tsx
Normal file
10
src/client/web/src/components/__test__/browser.test.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { init } from "../core_state";
|
||||
import { Updater } from "../browser";
|
||||
import { MockUsersClient } from "../../client/users_mock";
|
||||
|
||||
describe("Browser", () => {
|
||||
test("Updater: ", () => {
|
||||
});
|
||||
});
|
151
src/client/web/src/components/auth_pane.tsx
Normal file
151
src/client/web/src/components/auth_pane.tsx
Normal file
|
@ -0,0 +1,151 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { ICoreState } from "./core_state";
|
||||
import { IUsersClient } from "../client";
|
||||
import { UsersClient } from "../client/users";
|
||||
|
||||
export interface Props {
|
||||
authed: boolean;
|
||||
update?: (updater: (prevState: ICoreState) => ICoreState) => void;
|
||||
}
|
||||
|
||||
export class Updater {
|
||||
private static props: Props;
|
||||
private static client: IUsersClient;
|
||||
|
||||
static init = (props: Props) => (Updater.props = { ...props });
|
||||
|
||||
static setClient = (client: IUsersClient): void => {
|
||||
Updater.client = client;
|
||||
};
|
||||
|
||||
static login = async (user: string, pwd: string): Promise<boolean> => {
|
||||
const resp = await Updater.client.login(user, pwd);
|
||||
Updater.setAuthed(resp.status === 200);
|
||||
return resp.status === 200;
|
||||
};
|
||||
|
||||
static logout = async (): Promise<boolean> => {
|
||||
const resp = await Updater.client.logout();
|
||||
Updater.setAuthed(false);
|
||||
return resp.status === 200;
|
||||
};
|
||||
|
||||
static isAuthed = async (): Promise<boolean> => {
|
||||
const resp = await Updater.client.isAuthed();
|
||||
return resp.status === 200;
|
||||
};
|
||||
|
||||
static initIsAuthed = async (): Promise<void> => {
|
||||
return Updater.isAuthed().then((isAuthed) => {
|
||||
Updater.setAuthed(isAuthed);
|
||||
});
|
||||
};
|
||||
|
||||
static setAuthed = (isAuthed: boolean) => {
|
||||
Updater.props.authed = isAuthed;
|
||||
};
|
||||
|
||||
static setAuthPane = (preState: ICoreState): ICoreState => {
|
||||
preState.panel.authPane = {
|
||||
...preState.panel.authPane,
|
||||
...Updater.props,
|
||||
};
|
||||
return preState;
|
||||
};
|
||||
}
|
||||
|
||||
export interface State {
|
||||
user: string;
|
||||
pwd: string;
|
||||
}
|
||||
|
||||
export class AuthPane extends React.Component<Props, State, {}> {
|
||||
private update: (updater: (prevState: ICoreState) => ICoreState) => void;
|
||||
constructor(p: Props) {
|
||||
super(p);
|
||||
Updater.init(p);
|
||||
Updater.setClient(new UsersClient(""));
|
||||
this.update = p.update;
|
||||
this.state = {
|
||||
user: "",
|
||||
pwd: "",
|
||||
};
|
||||
|
||||
this.initIsAuthed();
|
||||
}
|
||||
|
||||
changeUser = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ user: ev.target.value });
|
||||
};
|
||||
|
||||
changePwd = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ pwd: ev.target.value });
|
||||
};
|
||||
|
||||
initIsAuthed = () => {
|
||||
Updater.initIsAuthed().then(() => {
|
||||
this.update(Updater.setAuthPane);
|
||||
});
|
||||
};
|
||||
|
||||
login = () => {
|
||||
Updater.login(this.state.user, this.state.pwd).then((ok: boolean) => {
|
||||
if (ok) {
|
||||
this.update(Updater.setAuthPane);
|
||||
this.setState({ user: "", pwd: "" });
|
||||
} else {
|
||||
alert("Failed to login.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
logout = () => {
|
||||
Updater.logout().then((ok: boolean) => {
|
||||
if (ok) {
|
||||
this.update(Updater.setAuthPane);
|
||||
this.setState({ user: "", pwd: "" });
|
||||
} else {
|
||||
alert("Failed to login.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<span>
|
||||
<span style={{ display: this.props.authed ? "none" : "inherit" }}>
|
||||
<input
|
||||
name="user"
|
||||
type="text"
|
||||
onChange={this.changeUser}
|
||||
value={this.state.user}
|
||||
className="margin-r-m black0-font"
|
||||
style={{ width: "6rem" }}
|
||||
placeholder="user name"
|
||||
/>
|
||||
<input
|
||||
name="pwd"
|
||||
type="password"
|
||||
onChange={this.changePwd}
|
||||
value={this.state.pwd}
|
||||
className="margin-r-m black0-font"
|
||||
style={{ width: "6rem" }}
|
||||
placeholder="password"
|
||||
/>
|
||||
<button onClick={this.login} className="green0-bg white-font">
|
||||
Log in
|
||||
</button>
|
||||
</span>
|
||||
<span style={{ display: this.props.authed ? "inherit" : "none" }}>
|
||||
<button
|
||||
onClick={this.logout}
|
||||
className="grey1-bg white-font margin-r-m"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
549
src/client/web/src/components/browser.tsx
Normal file
549
src/client/web/src/components/browser.tsx
Normal file
|
@ -0,0 +1,549 @@
|
|||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { List, Map } from "immutable";
|
||||
import * as Filesize from "filesize";
|
||||
|
||||
import { ICoreState } from "./core_state";
|
||||
import { IUsersClient, IFilesClient, MetadataResp } from "../client";
|
||||
import { FilesClient } from "../client/files";
|
||||
import { UsersClient } from "../client/users";
|
||||
import { FileUploader } from "../client/uploader";
|
||||
|
||||
export const uploadCheckCycle = 1000;
|
||||
|
||||
export interface Item {
|
||||
name: string;
|
||||
size: number;
|
||||
modTime: string;
|
||||
isDir: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
export interface Props {
|
||||
dirPath: List<string>;
|
||||
items: List<MetadataResp>;
|
||||
|
||||
uploadFiles: List<File>;
|
||||
uploadValue: string;
|
||||
|
||||
update?: (updater: (prevState: ICoreState) => ICoreState) => void;
|
||||
}
|
||||
|
||||
function getItemPath(dirPath: string, itemName: string): string {
|
||||
return dirPath.endsWith("/")
|
||||
? `${dirPath}${itemName}`
|
||||
: `${dirPath}/${itemName}`;
|
||||
}
|
||||
|
||||
export class Updater {
|
||||
private static props: Props;
|
||||
private static usersClient: IUsersClient;
|
||||
private static filesClient: IFilesClient;
|
||||
|
||||
static init = (props: Props) => (Updater.props = { ...props });
|
||||
static setClients(usersClient: IUsersClient, filesClient: IFilesClient) {
|
||||
Updater.usersClient = usersClient;
|
||||
Updater.filesClient = filesClient;
|
||||
}
|
||||
|
||||
static setItems = async (dirParts: List<string>): Promise<void> => {
|
||||
let dirPath = dirParts.join("/");
|
||||
let listResp = await Updater.filesClient.list(dirPath);
|
||||
|
||||
Updater.props.dirPath = dirParts;
|
||||
Updater.props.items =
|
||||
listResp != null
|
||||
? List<MetadataResp>(listResp.data.metadatas)
|
||||
: Updater.props.items;
|
||||
};
|
||||
|
||||
static mkDir = async (dirPath: string): Promise<void> => {
|
||||
let resp = await Updater.filesClient.mkdir(dirPath);
|
||||
if (resp.status !== 200) {
|
||||
alert(`failed to make dir ${dirPath}`);
|
||||
}
|
||||
};
|
||||
|
||||
static delete = async (
|
||||
dirParts: List<string>,
|
||||
items: List<MetadataResp>,
|
||||
selectedItems: Map<string, boolean>
|
||||
): Promise<void> => {
|
||||
const delRequests = items
|
||||
.filter((item) => {
|
||||
return selectedItems.has(item.name);
|
||||
})
|
||||
.map(
|
||||
async (selectedItem: MetadataResp): Promise<string> => {
|
||||
const itemPath = getItemPath(dirParts.join("/"), selectedItem.name);
|
||||
const resp = await Updater.filesClient.delete(itemPath);
|
||||
return resp.status === 200 ? "" : selectedItem.name;
|
||||
}
|
||||
);
|
||||
|
||||
const failedFiles = await Promise.all(delRequests);
|
||||
failedFiles.forEach((failedFile) => {
|
||||
if (failedFile !== "") {
|
||||
alert(`failed to delete ${failedFile}`);
|
||||
}
|
||||
});
|
||||
return Updater.setItems(dirParts);
|
||||
};
|
||||
|
||||
static moveHere = async (
|
||||
srcDir: string,
|
||||
dstDir: string,
|
||||
selectedItems: Map<string, boolean>
|
||||
): Promise<void> => {
|
||||
const moveRequests = List<string>(selectedItems.keys()).map(
|
||||
async (itemName: string): Promise<string> => {
|
||||
const oldPath = getItemPath(srcDir, itemName);
|
||||
const newPath = getItemPath(dstDir, itemName);
|
||||
|
||||
const resp = await Updater.filesClient.move(oldPath, newPath);
|
||||
return resp.status === 200 ? "" : itemName;
|
||||
}
|
||||
);
|
||||
|
||||
const failedFiles = await Promise.all(moveRequests);
|
||||
failedFiles.forEach((failedItem) => {
|
||||
if (failedItem !== "") {
|
||||
alert(`failed to move ${failedItem}`);
|
||||
}
|
||||
});
|
||||
|
||||
return Updater.setItems(List<string>(dstDir.split("/")));
|
||||
};
|
||||
|
||||
static setPwd = async (oldPwd: string, newPwd: string): Promise<boolean> => {
|
||||
const resp = await Updater.usersClient.setPwd(oldPwd, newPwd);
|
||||
return resp.status === 200;
|
||||
};
|
||||
|
||||
static addUploadFiles = (fileList: FileList, len: number) => {
|
||||
let newUploads = List<File>([]);
|
||||
for (let i = 0; i < len; i++) {
|
||||
newUploads = newUploads.push(fileList.item(i));
|
||||
}
|
||||
|
||||
Updater.props.uploadFiles = Updater.props.uploadFiles.concat(newUploads);
|
||||
};
|
||||
|
||||
static setUploadFiles = (uploadFiles: List<File>) => {
|
||||
Updater.props.uploadFiles = uploadFiles;
|
||||
};
|
||||
|
||||
static setBrowser = (prevState: ICoreState): ICoreState => {
|
||||
prevState.panel.browser = { ...prevState.panel, ...Updater.props };
|
||||
return prevState;
|
||||
};
|
||||
}
|
||||
|
||||
export interface State {
|
||||
inputValue: string;
|
||||
selectedSrc: string;
|
||||
selectedItems: Map<string, boolean>;
|
||||
|
||||
show: boolean;
|
||||
oldPwd: string;
|
||||
newPwd1: string;
|
||||
newPwd2: string;
|
||||
}
|
||||
|
||||
export class Browser extends React.Component<Props, State, {}> {
|
||||
private update: (updater: (prevState: ICoreState) => ICoreState) => void;
|
||||
private uploadInput: Element | Text;
|
||||
private assignInput: (input: Element) => void;
|
||||
private onClickUpload: () => void;
|
||||
private uploading: boolean;
|
||||
|
||||
constructor(p: Props) {
|
||||
super(p);
|
||||
Updater.init(p);
|
||||
Updater.setClients(new UsersClient(""), new FilesClient(""));
|
||||
this.update = p.update;
|
||||
this.state = {
|
||||
inputValue: "",
|
||||
selectedSrc: "",
|
||||
selectedItems: Map<string, boolean>(),
|
||||
show: false,
|
||||
oldPwd: "",
|
||||
newPwd1: "",
|
||||
newPwd2: "",
|
||||
};
|
||||
|
||||
this.uploadInput = undefined;
|
||||
this.assignInput = (input) => {
|
||||
this.uploadInput = ReactDOM.findDOMNode(input);
|
||||
};
|
||||
this.onClickUpload = () => {
|
||||
const uploadInput = this.uploadInput as HTMLButtonElement;
|
||||
uploadInput.click();
|
||||
};
|
||||
|
||||
Updater.setItems(p.dirPath).then(() => {
|
||||
this.update(Updater.setBrowser);
|
||||
});
|
||||
|
||||
setInterval(this.pollUploads, uploadCheckCycle);
|
||||
}
|
||||
|
||||
pollUploads = () => {
|
||||
if (this.props.uploadFiles.size > 0 && !this.uploading) {
|
||||
this.uploading = true;
|
||||
const file = this.props.uploadFiles.get(0);
|
||||
Updater.setUploadFiles(this.props.uploadFiles.slice(1));
|
||||
this.update(Updater.setBrowser);
|
||||
|
||||
const uploader = new FileUploader(
|
||||
file,
|
||||
`${this.props.dirPath.join("/")}/${file.name}`,
|
||||
this.updateProgress
|
||||
);
|
||||
|
||||
uploader.start().then((ok: boolean) => {
|
||||
Updater.setItems(this.props.dirPath).then(() => {
|
||||
this.update(Updater.setBrowser);
|
||||
});
|
||||
if (!ok) {
|
||||
alert(`upload failed: ${uploader.err()}`);
|
||||
}
|
||||
this.uploading = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateProgress = (filePath: string, progress: number) => {
|
||||
// update uploading progress in the core state
|
||||
};
|
||||
|
||||
showPane = () => {
|
||||
this.setState({ show: !this.state.show });
|
||||
};
|
||||
changeOldPwd = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ oldPwd: ev.target.value });
|
||||
};
|
||||
changeNewPwd1 = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ newPwd1: ev.target.value });
|
||||
};
|
||||
changeNewPwd2 = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ newPwd2: ev.target.value });
|
||||
};
|
||||
onInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ inputValue: ev.target.value });
|
||||
};
|
||||
|
||||
addUploadFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
Updater.addUploadFiles(event.target.files, event.target.files.length);
|
||||
this.update(Updater.setBrowser);
|
||||
};
|
||||
|
||||
select = (itemName: string) => {
|
||||
const selectedItems = this.state.selectedItems.has(itemName)
|
||||
? this.state.selectedItems.delete(itemName)
|
||||
: this.state.selectedItems.set(itemName, true);
|
||||
|
||||
this.setState({
|
||||
selectedSrc: this.props.dirPath.join("/"),
|
||||
selectedItems: selectedItems,
|
||||
});
|
||||
};
|
||||
|
||||
onMkDir = () => {
|
||||
Updater.mkDir(this.state.inputValue)
|
||||
.then(() => {
|
||||
this.setState({ inputValue: "" });
|
||||
return Updater.setItems(this.props.dirPath);
|
||||
})
|
||||
.then(() => {
|
||||
this.update(Updater.setBrowser);
|
||||
});
|
||||
};
|
||||
|
||||
delete = () => {
|
||||
if (this.props.dirPath.join("/") !== this.state.selectedSrc) {
|
||||
alert("please select file or folder to delete at first");
|
||||
this.setState({
|
||||
selectedSrc: this.props.dirPath.join("/"),
|
||||
selectedItems: Map<string, boolean>(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Updater.delete(
|
||||
this.props.dirPath,
|
||||
this.props.items,
|
||||
this.state.selectedItems
|
||||
).then(() => {
|
||||
this.update(Updater.setBrowser);
|
||||
this.setState({
|
||||
selectedSrc: "",
|
||||
selectedItems: Map<string, boolean>(),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
gotoChild = (childDirName: string) => {
|
||||
this.chdir(this.props.dirPath.push(childDirName));
|
||||
};
|
||||
|
||||
chdir = (dirPath: List<string>) => {
|
||||
Updater.setItems(dirPath).then(() => {
|
||||
this.update(Updater.setBrowser);
|
||||
});
|
||||
};
|
||||
|
||||
moveHere = () => {
|
||||
const oldDir = this.state.selectedSrc;
|
||||
const newDir = this.props.dirPath.join("/");
|
||||
if (oldDir === newDir) {
|
||||
alert("source directory is same as destination directory");
|
||||
return;
|
||||
}
|
||||
|
||||
Updater.moveHere(
|
||||
this.state.selectedSrc,
|
||||
this.props.dirPath.join("/"),
|
||||
this.state.selectedItems
|
||||
).then(() => {
|
||||
this.update(Updater.setBrowser);
|
||||
this.setState({
|
||||
selectedSrc: "",
|
||||
selectedItems: Map<string, boolean>(),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
setPwd = () => {
|
||||
if (this.state.newPwd1 !== this.state.newPwd2) {
|
||||
alert("new passwords are not same");
|
||||
} else if (this.state.newPwd1 == "") {
|
||||
alert("new passwords can not be empty");
|
||||
} else if (this.state.oldPwd == this.state.newPwd1) {
|
||||
alert("old and new passwords are same");
|
||||
} else {
|
||||
Updater.setPwd(this.state.oldPwd, this.state.newPwd1).then(
|
||||
(ok: boolean) => {
|
||||
if (ok) {
|
||||
alert("Password is updated");
|
||||
} else {
|
||||
alert("fail to update password");
|
||||
}
|
||||
this.setState({
|
||||
oldPwd: "",
|
||||
newPwd1: "",
|
||||
newPwd2: "",
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const breadcrumb = this.props.dirPath.map(
|
||||
(pathPart: string, key: number) => {
|
||||
return (
|
||||
<span key={pathPart}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => this.chdir(this.props.dirPath.slice(0, key + 1))}
|
||||
className="white-font margin-r-m"
|
||||
style={{ backgroundColor: "rgba(0, 0, 0, 0.7)" }}
|
||||
>
|
||||
{pathPart}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const ops = (
|
||||
<div>
|
||||
<div className="grey0-font">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => this.delete()}
|
||||
className="red0-bg white-font margin-m"
|
||||
>
|
||||
Delete Selected
|
||||
</button>
|
||||
<span className="margin-s">-</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => this.moveHere()}
|
||||
className="grey1-bg white-font margin-m"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
<span className="margin-s">-</span>
|
||||
<button
|
||||
onClick={this.onClickUpload}
|
||||
className="green0-bg white-font margin-m"
|
||||
>
|
||||
Upload Files
|
||||
</button>
|
||||
<span className="margin-s">-</span>
|
||||
<span className="margin-m">
|
||||
<input
|
||||
type="text"
|
||||
onChange={this.onInputChange}
|
||||
value={this.state.inputValue}
|
||||
className="margin-r-m black0-font"
|
||||
placeholder="folder name"
|
||||
/>
|
||||
<button onClick={this.onMkDir} className="grey1-bg white-font">
|
||||
Create Folder
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
onChange={this.addUploadFile}
|
||||
multiple={true}
|
||||
value={this.props.uploadValue}
|
||||
ref={this.assignInput}
|
||||
className="black0-font hidden margin-m"
|
||||
/>
|
||||
<span className="margin-s">-</span>
|
||||
<button
|
||||
onClick={this.showPane}
|
||||
className="grey1-bg white-font margin-m"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ display: this.state.show ? "inherit" : "none" }}
|
||||
className="margin-t-m"
|
||||
>
|
||||
<h3 className="padding-l-s grey0-font">Update Password</h3>
|
||||
<input
|
||||
name="old_pwd"
|
||||
type="password"
|
||||
onChange={this.changeOldPwd}
|
||||
value={this.state.oldPwd}
|
||||
className="margin-m black0-font"
|
||||
placeholder="old password"
|
||||
/>
|
||||
<input
|
||||
name="new_pwd1"
|
||||
type="password"
|
||||
onChange={this.changeNewPwd1}
|
||||
value={this.state.newPwd1}
|
||||
className="margin-m black0-font"
|
||||
placeholder="new password"
|
||||
/>
|
||||
<input
|
||||
name="new_pwd2"
|
||||
type="password"
|
||||
onChange={this.changeNewPwd2}
|
||||
value={this.state.newPwd2}
|
||||
className="margin-m black0-font"
|
||||
placeholder="new password again"
|
||||
/>
|
||||
<button onClick={this.setPwd} className="grey1-bg white-font">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const itemList = this.props.items.map((item: MetadataResp) => {
|
||||
const isSelected = this.state.selectedItems.has(item.name);
|
||||
const dirPath = this.props.dirPath.join("/");
|
||||
const itemPath = dirPath.endsWith("/")
|
||||
? `${dirPath}${item.name}`
|
||||
: `${dirPath}/${item.name}`;
|
||||
|
||||
return item.isDir ? (
|
||||
<tr key={item.name} className={`${isSelected ? "green0-bg" : ""}`}>
|
||||
<td className="padding-l-l" style={{ width: "3rem" }}>
|
||||
<span className="dot yellow0-bg"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="item-name"
|
||||
onClick={() => this.gotoChild(item.name)}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</td>
|
||||
<td>N/A</td>
|
||||
<td>{item.modTime.slice(0, item.modTime.indexOf("T"))}</td>
|
||||
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => this.select(item.name)}
|
||||
className="grey1-bg white-font margin-t-m margin-b-m"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={item.name} className={`${isSelected ? "green0-bg" : ""}`}>
|
||||
<td className="padding-l-l" style={{ width: "3rem" }}>
|
||||
<span className="dot green0-bg"></span>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
className="item-name"
|
||||
href={`/v1/fs/files?fp=${itemPath}`}
|
||||
target="_blank"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
</td>
|
||||
<td>{Filesize(item.size, {round: 0})}</td>
|
||||
<td>{item.modTime.slice(0, item.modTime.indexOf("T"))}</td>
|
||||
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => this.select(item.name)}
|
||||
className="grey1-bg white-font margin-t-m margin-b-m"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div id="op-bar" className="op-bar">
|
||||
<div className="margin-l-m margin-r-m">{ops}</div>
|
||||
</div>
|
||||
|
||||
<div id="item-list" className="">
|
||||
<div className="margin-b-l">{breadcrumb}</div>
|
||||
<table>
|
||||
<thead style={{ fontWeight: "bold" }}>
|
||||
<tr>
|
||||
<td className="padding-l-l" style={{ width: "3rem" }}>
|
||||
<span className="dot black-bg"></span>
|
||||
</td>
|
||||
<td>Name</td>
|
||||
<td>File Size</td>
|
||||
<td>Mod Time</td>
|
||||
<td>Op</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{itemList}</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
32
src/client/web/src/components/core_state.ts
Normal file
32
src/client/web/src/components/core_state.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { List } from "immutable";
|
||||
|
||||
import { Props as PanelProps } from "./panel";
|
||||
import { Item } from "./browser";
|
||||
|
||||
|
||||
export interface IContext {
|
||||
update: (targetStatePatch: any) => void;
|
||||
}
|
||||
|
||||
export interface ICoreState {
|
||||
ctx: IContext;
|
||||
panel: PanelProps;
|
||||
}
|
||||
|
||||
export function init(): ICoreState {
|
||||
return {
|
||||
ctx: null,
|
||||
panel: {
|
||||
displaying: "browser",
|
||||
authPane: {
|
||||
authed: false,
|
||||
},
|
||||
browser: {
|
||||
dirPath: List<string>(["."]),
|
||||
items: List<Item>([]),
|
||||
uploadValue: "",
|
||||
uploadFiles: List<File>([]),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
68
src/client/web/src/components/panel.tsx
Normal file
68
src/client/web/src/components/panel.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { ICoreState } from "./core_state";
|
||||
import { Browser, Props as BrowserProps } from "./browser";
|
||||
import { AuthPane, Props as AuthPaneProps } from "./auth_pane";
|
||||
|
||||
export interface Props {
|
||||
displaying: string;
|
||||
browser: BrowserProps;
|
||||
authPane: AuthPaneProps;
|
||||
update?: (updater: (prevState: ICoreState) => ICoreState) => void;
|
||||
}
|
||||
|
||||
export class Updater {
|
||||
private static props: Props;
|
||||
|
||||
static init = (props: Props) => (Updater.props = { ...props });
|
||||
|
||||
static setPanel = (prevState: ICoreState): ICoreState => {
|
||||
return {
|
||||
...prevState,
|
||||
panel: { ...prevState.panel, ...Updater.props },
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface State {}
|
||||
export class Panel extends React.Component<Props, State, {}> {
|
||||
private update: (updater: (prevState: ICoreState) => ICoreState) => void;
|
||||
constructor(p: Props) {
|
||||
super(p);
|
||||
Updater.init(p);
|
||||
this.update = p.update;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="theme-white desktop">
|
||||
<div id="bg" className="bg bg-img font-m">
|
||||
<div
|
||||
id="top-bar"
|
||||
className="top-bar cyan1-font padding-t-m padding-b-m padding-l-l padding-r-l"
|
||||
>
|
||||
<div className="flex-2col-parent">
|
||||
<span className="flex-13col h5">Quickshare</span>
|
||||
<span className="flex-23col text-right">
|
||||
<AuthPane
|
||||
authed={this.props.authPane.authed}
|
||||
update={this.update}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="container-center">
|
||||
<Browser
|
||||
dirPath={this.props.browser.dirPath}
|
||||
items={this.props.browser.items}
|
||||
update={this.update}
|
||||
uploadFiles={this.props.browser.uploadFiles}
|
||||
uploadValue={this.props.browser.uploadValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
37
src/client/web/src/components/state_mgr.tsx
Normal file
37
src/client/web/src/components/state_mgr.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { ICoreState, init } from "./core_state";
|
||||
import { Panel } from "./panel";
|
||||
|
||||
export interface Props {}
|
||||
export interface State extends ICoreState {}
|
||||
|
||||
export class UpdaterBase {
|
||||
private static props: any;
|
||||
static init = (props: any) => (UpdaterBase.props = {...props});
|
||||
}
|
||||
|
||||
export class StateMgr extends React.Component<Props, State, {}> {
|
||||
constructor(p: Props) {
|
||||
super(p);
|
||||
this.state = init();
|
||||
}
|
||||
|
||||
// TODO: any can be eliminated by adding union type of children states
|
||||
update = (updater: (prevState:ICoreState) => ICoreState): void => {
|
||||
console.log("before", this.state)
|
||||
this.setState(updater(this.state));
|
||||
console.log("after", this.state)
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Panel
|
||||
authPane = {this.state.panel.authPane}
|
||||
displaying={this.state.panel.displaying}
|
||||
update={this.update}
|
||||
browser={this.state.panel.browser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
99
src/client/web/src/theme/animation.css
Normal file
99
src/client/web/src/theme/animation.css
Normal file
|
@ -0,0 +1,99 @@
|
|||
@charset "utf-8";
|
||||
|
||||
.anm-rotate {
|
||||
animation: rotate 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
20% {
|
||||
transform: rotate(72deg);
|
||||
}
|
||||
40% {
|
||||
transform: rotate(144deg);
|
||||
}
|
||||
60% {
|
||||
transform: rotate(216deg);
|
||||
}
|
||||
80% {
|
||||
transform: rotate(288deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
/* animation-direction: reverse; */
|
||||
animation: fade-in 0.2s 1 linear;
|
||||
opacity: 0.5;
|
||||
/* padding: 0; */
|
||||
transform: translate(0, 0.4rem);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
transform: translate(0, 0.4rem);
|
||||
/* padding: 0.4rem 0; */
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
/* transform: translate(0, 0.4rem); */
|
||||
/* padding: 0; */
|
||||
}
|
||||
}
|
||||
|
||||
.notification-resize {
|
||||
animation: resize 2s 1 linear;
|
||||
/* transform: translate(100%, 0%); */
|
||||
}
|
||||
|
||||
@keyframes resize {
|
||||
0% {
|
||||
/* transform: translate(0, 0); */
|
||||
opacity: 0;
|
||||
transform: scale(1, 0);
|
||||
}
|
||||
5% {
|
||||
/* transform: translate(0, 8rem); */
|
||||
opacity: 0.8;
|
||||
transform: scale(1, 1);
|
||||
}
|
||||
95% {
|
||||
/* transform: translate(0, 8rem); */
|
||||
opacity: 0.8;
|
||||
transform: scale(1, 1);
|
||||
}
|
||||
100% {
|
||||
/* transform: translate(0, 0rem); */
|
||||
opacity: 0;
|
||||
transform: scale(1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-cool-down {
|
||||
animation: cooldown 0.3s 1 linear;
|
||||
}
|
||||
|
||||
@keyframes cooldown {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.opacity-trans {
|
||||
transition: opacity 0.15s linear;
|
||||
}
|
375
src/client/web/src/theme/color.css
Normal file
375
src/client/web/src/theme/color.css
Normal file
|
@ -0,0 +1,375 @@
|
|||
.blue0-font {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.blue1-font {
|
||||
color: #2980b9;
|
||||
}
|
||||
|
||||
.cyan0-font {
|
||||
color: #1abc9c;
|
||||
}
|
||||
|
||||
.cyan1-font {
|
||||
color: #16a085;
|
||||
}
|
||||
|
||||
.purple0-font {
|
||||
color: #9b59b6;
|
||||
}
|
||||
|
||||
.purple1-font {
|
||||
color: #8e44ad;
|
||||
}
|
||||
|
||||
.red0-font {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.red1-font {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.yellow0-font {
|
||||
color: #f1c40f;
|
||||
}
|
||||
|
||||
.yellow1-font {
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.yellow2-font {
|
||||
color: #e67e22;
|
||||
}
|
||||
|
||||
.yellow3-font {
|
||||
color: #d35400;
|
||||
}
|
||||
|
||||
.green0-font {
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.green1-font {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.white-font {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.white0-font {
|
||||
color: #ecf0f1;
|
||||
}
|
||||
|
||||
.white1-font {
|
||||
color: #bdc3c7;
|
||||
}
|
||||
|
||||
.grey0-font {
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.grey1-font {
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.black-font {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.black0-font {
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
.black1-font {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.blue0-bg {
|
||||
background-color: #3498db;
|
||||
}
|
||||
|
||||
.blue1-bg {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.cyan0-bg {
|
||||
background-color: #1abc9c;
|
||||
}
|
||||
|
||||
.cyan1-bg {
|
||||
background-color: #16a085;
|
||||
}
|
||||
|
||||
.purple0-bg {
|
||||
background-color: #9b59b6;
|
||||
}
|
||||
|
||||
.purple1-bg {
|
||||
background-color: #8e44ad;
|
||||
}
|
||||
|
||||
.red0-bg {
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
|
||||
.red1-bg {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
|
||||
.yellow0-bg {
|
||||
background-color: #f1c40f;
|
||||
}
|
||||
|
||||
.yellow1-bg {
|
||||
background-color: #f39c12;
|
||||
}
|
||||
|
||||
.yellow2-bg {
|
||||
background-color: #e67e22;
|
||||
}
|
||||
|
||||
.yellow3-bg {
|
||||
background-color: #d35400;
|
||||
}
|
||||
|
||||
.green0-bg {
|
||||
background-color: #2ecc71;
|
||||
}
|
||||
|
||||
.green1-bg {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
.white-bg {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.white0-bg {
|
||||
background-color: #ecf0f1;
|
||||
}
|
||||
|
||||
.white1-bg {
|
||||
background-color: #bdc3c7;
|
||||
}
|
||||
|
||||
.grey0-bg {
|
||||
background-color: #95a5a6;
|
||||
}
|
||||
|
||||
.grey1-bg {
|
||||
background-color: #7f8c8d;
|
||||
}
|
||||
|
||||
.black-bg {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.black0-bg {
|
||||
background-color: #34495e;
|
||||
}
|
||||
|
||||
.black1-bg {
|
||||
background-color: #2c3e50;
|
||||
}
|
||||
|
||||
.padding-xs {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.padding-s {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.padding-m {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.padding-l {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.padding-xl {
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.padding-l-xs {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.padding-l-s {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.padding-l-m {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.padding-l-l {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.padding-l-xl {
|
||||
padding-left: 4rem;
|
||||
}
|
||||
|
||||
.padding-r-xs {
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.padding-r-s {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.padding-r-m {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.padding-r-l {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.padding-r-xl {
|
||||
padding-right: 4rem;
|
||||
}
|
||||
|
||||
.padding-t-xs {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.padding-t-s {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.padding-t-m {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.padding-t-l {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.padding-t-xl {
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
.padding-b-xs {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.padding-b-s {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.padding-b-m {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.padding-b-l {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.padding-b-xl {
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.margin-xs {
|
||||
margin: 0.25rem;
|
||||
}
|
||||
|
||||
.margin-s {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.margin-m {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.margin-l {
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
.margin-xl {
|
||||
margin: 4rem;
|
||||
}
|
||||
|
||||
.margin-l-xs {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.margin-l-s {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.margin-l-m {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.margin-l-l {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
.margin-l-xl {
|
||||
margin-left: 4rem;
|
||||
}
|
||||
|
||||
.margin-r-xs {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.margin-r-s {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.margin-r-m {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.margin-r-l {
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.margin-r-xl {
|
||||
margin-right: 4rem;
|
||||
}
|
||||
|
||||
.margin-t-xs {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.margin-t-s {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.margin-t-m {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.margin-t-l {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.margin-t-xl {
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.margin-b-xs {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.margin-b-s {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.margin-b-m {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.margin-b-l {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.margin-b-xl {
|
||||
margin-bottom: 4rem;
|
||||
}
|
24
src/client/web/src/theme/desktop.css
Normal file
24
src/client/web/src/theme/desktop.css
Normal file
|
@ -0,0 +1,24 @@
|
|||
.desktop .font-xs {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.8rem;
|
||||
}
|
||||
|
||||
.desktop .font-s {
|
||||
font-size: 1.4rem;
|
||||
line-height: 2.1rem;
|
||||
}
|
||||
|
||||
.desktop .font-m {
|
||||
font-size: 1.6rem;
|
||||
line-height: 2.4rem;
|
||||
}
|
||||
|
||||
.desktop .font-l {
|
||||
font-size: 1.8rem;
|
||||
line-height: 2.7rem;
|
||||
}
|
||||
|
||||
.desktop .font-xl {
|
||||
font-size: 2.0rem;
|
||||
line-height: 3rem;
|
||||
}
|
118
src/client/web/src/theme/reset.css
Normal file
118
src/client/web/src/theme/reset.css
Normal file
|
@ -0,0 +1,118 @@
|
|||
@charset "utf-8";
|
||||
|
||||
html,
|
||||
body,
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
outline: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
html {
|
||||
background-color: #ecf0f1;
|
||||
/* background: url("bg.jpg") no-repeat left center fixed;
|
||||
background-size: auto 100%; */
|
||||
font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
|
||||
font-size: 62.5%;
|
||||
}
|
||||
body {
|
||||
line-height: 200%;
|
||||
}
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
a,
|
||||
a:link,
|
||||
a:visited,
|
||||
a:hover,
|
||||
a:active,
|
||||
button,
|
||||
img {
|
||||
-webkit-touch-callout: none; /* iOS Safari */
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-khtml-user-select: none; /* Konqueror HTML */
|
||||
-moz-user-select: none; /* Firefox */
|
||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||
user-select: none; /* Non-prefixed version, currently supported by Chrome and Opera */
|
||||
}
|
||||
a {
|
||||
color: #16a085;
|
||||
opacity: 100%;
|
||||
text-decoration: none;
|
||||
transition: color 1s 1 linear;
|
||||
}
|
||||
a:hover {
|
||||
color: #2ecc71;
|
||||
}
|
||||
a:active {
|
||||
}
|
||||
a::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
button:active {
|
||||
/* animation: fade 0.1s 1 linear; */
|
||||
}
|
||||
button:hover {
|
||||
/* animation: fade 0.1s 1 linear; */
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
text-align: left;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.4rem;
|
||||
height: 1.4rem;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
padding: 0.8rem 1rem;
|
||||
width: 10rem;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.8rem 1rem;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
|
||||
font-weight: bold;
|
||||
border-radius: 0.5rem;
|
||||
}
|
486
src/client/web/src/theme/style.css
Normal file
486
src/client/web/src/theme/style.css
Normal file
|
@ -0,0 +1,486 @@
|
|||
#bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
#top-bar {
|
||||
line-height: 3rem;
|
||||
}
|
||||
|
||||
#container-center {
|
||||
margin: 5rem auto auto auto;
|
||||
width: 96%;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
#op-bar {
|
||||
width: 96%;
|
||||
max-width: 960px;
|
||||
|
||||
position: fixed;
|
||||
top: 6rem;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
line-height: 3rem;
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
|
||||
#item-list {
|
||||
margin-top: 12rem;
|
||||
}
|
||||
|
||||
#item-list table {
|
||||
width: 100%;
|
||||
color: #34495e;
|
||||
font-size: 1.4rem;
|
||||
line-height: 4rem;
|
||||
background-color: white;
|
||||
border-radius: 0.8rem;
|
||||
margin-bottom: 8rem;
|
||||
}
|
||||
|
||||
#item-list tr {
|
||||
border-top: solid 1px transparent;
|
||||
}
|
||||
|
||||
#item-list .item-name {
|
||||
display: block;
|
||||
max-width: 8rem;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#item-list tr:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
#panel {
|
||||
position: fixed;
|
||||
top: 4rem;
|
||||
right: 0.5rem;
|
||||
bottom: 6rem;
|
||||
left: 0.5rem;
|
||||
|
||||
width: 80%;
|
||||
margin: auto;
|
||||
border-radius: 1.4rem;
|
||||
background-color: white;
|
||||
}
|
||||
#panel-head {
|
||||
text-align: left;
|
||||
/* box-shadow: 0 0.2rem 3rem rgba(0, 0, 0, 0.1); */
|
||||
|
||||
padding: 1rem 2rem 1rem 2rem;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: auto;
|
||||
left: 0;
|
||||
|
||||
font-size: 2.0rem;
|
||||
line-height: 3.9rem;
|
||||
}
|
||||
|
||||
#panel-head-menu {
|
||||
text-align: right;
|
||||
|
||||
/* height: 3rem; */
|
||||
|
||||
position: absolute;
|
||||
top: 4rem;
|
||||
right: 0;
|
||||
bottom: auto;
|
||||
left: 0;
|
||||
/* display: flex;
|
||||
justify-content: space-between; */
|
||||
padding: 1rem 2rem;
|
||||
line-height: 4rem;
|
||||
/* background-color: rgba(255, 255, 255, 0.6); */
|
||||
}
|
||||
.text-header {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
color: #16a085;
|
||||
margin: auto;
|
||||
line-height: 4rem;
|
||||
}
|
||||
#panel-body {
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
|
||||
position: absolute;
|
||||
top: 6rem;
|
||||
right: 0;
|
||||
bottom: 0.5rem;
|
||||
left: 0;
|
||||
/* box-shadow: 0 0.2rem 0.6rem rgba(0, 0, 0, 0.1); */
|
||||
}
|
||||
#nav-bar {
|
||||
text-align: center;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 0 1rem 0;
|
||||
|
||||
position: absolute;
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 5rem;
|
||||
box-shadow: 0 -0.2rem 3rem rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.nav-bar-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.win-left-bar {
|
||||
text-align: right;
|
||||
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
/* right: auto; */
|
||||
/* bottom: 0.5rem; */
|
||||
left: -0.5rem;
|
||||
/* box-shadow: 0 0.2rem 0.6rem rgba(0, 0, 0, 0.1); */
|
||||
}
|
||||
|
||||
.win-right-bar {
|
||||
text-align: left;
|
||||
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
/* right: auto; */
|
||||
/* bottom: 0.5rem; */
|
||||
right: -0.5rem;
|
||||
/* box-shadow: 0 0.2rem 0.6rem rgba(0, 0, 0, 0.1); */
|
||||
}
|
||||
.right-bar {
|
||||
font-size: 1.3rem;
|
||||
line-height: 3rem;
|
||||
|
||||
width: 20rem;
|
||||
position: absolute;
|
||||
left: 2rem;
|
||||
top: 4rem;
|
||||
}
|
||||
.link-list {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.ad-wrap {
|
||||
padding: 0 0 1rem 0;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
.ad-wrap-title {
|
||||
line-height: 200%;
|
||||
}
|
||||
|
||||
/* layoout */
|
||||
.flex-2col-parent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.flex-2col {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 50%;
|
||||
}
|
||||
.flex-23col {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 66%;
|
||||
}
|
||||
.flex-13col {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 31%;
|
||||
}
|
||||
.flex-col-parent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.flex-col {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.clearfix {
|
||||
clear: both;
|
||||
}
|
||||
.section {
|
||||
text-align: left;
|
||||
/* margin-bottom: 2rem; */
|
||||
padding: 2rem;
|
||||
}
|
||||
.section-h {
|
||||
text-align: left;
|
||||
/* margin-bottom: 2rem; */
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
.font-grey {
|
||||
color: #999;
|
||||
}
|
||||
.margin-m {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
.margin-right-s {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.margin-right-m {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.margin-l {
|
||||
margin: 1rem;
|
||||
}
|
||||
.margin-h-l {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.margin-h-m {
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
.margin-right-l {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.margin-left-s {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.margin-left-m {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.margin-left-l {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
/* font */
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.weight-normal {
|
||||
font-weight: normal;
|
||||
}
|
||||
.h1,
|
||||
.h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.h3,
|
||||
.h4 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.h5,
|
||||
.h6 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.noselect {
|
||||
-webkit-touch-callout: none; /* iOS Safari */
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-khtml-user-select: none; /* Konqueror HTML */
|
||||
-moz-user-select: none; /* Firefox */
|
||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||
user-select: none; /* Non-prefixed version, currently supported by Chrome and Opera */
|
||||
}
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* component */
|
||||
.icon-s {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
margin: -0.2rem 0.5rem auto 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.btn {
|
||||
font-size: 1.4rem;
|
||||
line-height: 2.4rem;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 2rem;
|
||||
}
|
||||
.pane {
|
||||
font-size: 1.4rem;
|
||||
text-align: left;
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
padding: 8rem 0 10rem 0;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
.menu-container {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.menu {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 1.4rem;
|
||||
line-height: 2rem;
|
||||
text-align: center;
|
||||
padding: 1rem 1rem;
|
||||
display: inline-block;
|
||||
border-radius: 4rem;
|
||||
}
|
||||
.wide-btn {
|
||||
color: white;
|
||||
text-align: center;
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
margin: 2rem 2rem 6rem 2rem;
|
||||
border-radius: 2rem;
|
||||
}
|
||||
.border {
|
||||
height: 1px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.notification-container {
|
||||
position: absolute;
|
||||
top: 10rem;
|
||||
right: 0;
|
||||
bottom: auto;
|
||||
left: 0;
|
||||
max-height: 10rem;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
max-width: 48rem;
|
||||
}
|
||||
.notification {
|
||||
/* color: white; */
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
/* font-weight: bold; */
|
||||
font-size: 1.4rem;
|
||||
line-height: 2rem;
|
||||
text-align: center;
|
||||
padding: 1rem 2rem;
|
||||
display: inline-block;
|
||||
border-radius: 1rem;
|
||||
margin: 0.5rem 0.5rem;
|
||||
}
|
||||
.notification-ok {
|
||||
color: white;
|
||||
fill: #2ecc71;
|
||||
}
|
||||
.notification-error {
|
||||
color: white;
|
||||
fill: #e74c3c;
|
||||
}
|
||||
.notification-warn {
|
||||
color: white;
|
||||
fill: #f1c40f;
|
||||
}
|
||||
.font-size-m {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.news {
|
||||
font-size: 1.2rem;
|
||||
line-height: 4rem;
|
||||
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.news .title {
|
||||
font-size: 1.6rem;
|
||||
line-height: 2.5rem;
|
||||
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.news .desc {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.8rem;
|
||||
}
|
||||
.bottom-line {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
color: #16a085;
|
||||
}
|
||||
|
||||
select {
|
||||
background: white;
|
||||
border: transparent;
|
||||
color: black;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.dot {
|
||||
border-radius: 50%;
|
||||
height: 0.8rem;
|
||||
width: 0.8rem;
|
||||
display: inline-block;
|
||||
line-height: 3rem;
|
||||
}
|
||||
|
||||
input.white-square {
|
||||
border: solid 2px #fff;
|
||||
background: transparent;
|
||||
padding: 0.4rem 0.8rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: inline-block;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0.6rem;
|
||||
line-height: 3rem;
|
||||
}
|
||||
|
||||
.grid .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.grid .desc {
|
||||
font-size: 1.2rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.grid-dot {
|
||||
position: relative;
|
||||
right: -1rem;
|
||||
top: -1rem;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.hr {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
/*
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.4rem;
|
||||
line-height: 1.6rem;
|
||||
font-weight: normal;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.row {
|
||||
text-align: left;
|
||||
padding: 1rem 0;
|
||||
} */
|
69
src/client/web/src/theme/white.css
Normal file
69
src/client/web/src/theme/white.css
Normal file
|
@ -0,0 +1,69 @@
|
|||
.theme-white .bg {
|
||||
background-color: #ecf0f1;
|
||||
}
|
||||
|
||||
.theme-white .text-color {
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
.theme-white .bg-img {
|
||||
background: url("/static/img/textured_paper.png") repeat fixed center;
|
||||
/* background: url("/static/img/huangpu.jpg") repeat fixed center; */
|
||||
}
|
||||
|
||||
.theme-white .top-bar {
|
||||
background: rgba( 255, 255, 255, 0.9 );
|
||||
box-shadow: 0 5px 30px 0 rgba( 31, 38, 135, 0.1 );
|
||||
backdrop-filter: blur( 9.5px );
|
||||
-webkit-backdrop-filter: blur( 9.5px );
|
||||
}
|
||||
|
||||
.theme-white div.hr {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.theme-white .op-bar {
|
||||
background: rgba( 255, 255, 255, 0.9 );
|
||||
box-shadow: 0 5px 30px 0 rgba( 31, 38, 135, 0.1 );
|
||||
backdrop-filter: blur( 9.5px );
|
||||
-webkit-backdrop-filter: blur( 9.5px );
|
||||
}
|
||||
|
||||
.theme-white .panel {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.theme-white .panel-head {
|
||||
/* background-color: white; */
|
||||
border-bottom: solid 1px #ecf0f1;
|
||||
}
|
||||
|
||||
.theme-white .panel-head-menu {
|
||||
background-color: white;
|
||||
color: #34495e;
|
||||
border-bottom: solid 1px #ecf0f1;
|
||||
}
|
||||
|
||||
.theme-white .panel-body {
|
||||
/* background-color: #fafafa; */
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
.theme-white .nav-bar {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
color: #7f8c8d;
|
||||
fill: black;
|
||||
}
|
||||
|
||||
.theme-white .news .title {
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
.theme-white .border {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.theme-white .wide-btn {
|
||||
color: white;
|
||||
background-color: #16a085;
|
||||
}
|
13
src/client/web/tsconfig.json
Normal file
13
src/client/web/tsconfig.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/",
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"jsx": "react",
|
||||
"lib": ["es2015", "dom"]
|
||||
},
|
||||
"include": ["./src/**/*", "webpack.config.js", "webpack..js"],
|
||||
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
21
src/client/web/webpack.app.dev.js
Normal file
21
src/client/web/webpack.app.dev.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
const merge = require("webpack-merge");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
|
||||
const dev = require("./webpack.dev.js");
|
||||
|
||||
module.exports = merge(dev, {
|
||||
entry: "./src/app.tsx",
|
||||
context: `${__dirname}`,
|
||||
output: {
|
||||
path: `${__dirname}/../../../public/static`,
|
||||
chunkFilename: "[name].bundle.js",
|
||||
filename: "[name].bundle.js"
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: `${__dirname}/build/template/index.template.dev.html`,
|
||||
hash: true,
|
||||
filename: `../index.html`
|
||||
})
|
||||
]
|
||||
});
|
21
src/client/web/webpack.app.prod.js
Normal file
21
src/client/web/webpack.app.prod.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
const merge = require("webpack-merge");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
|
||||
const prod = require("./webpack.prod.js");
|
||||
|
||||
module.exports = merge(prod, {
|
||||
entry: "./src/app.tsx",
|
||||
context: `${__dirname}`,
|
||||
output: {
|
||||
path: `${__dirname}/../../../public/static`,
|
||||
chunkFilename: "[name].bundle.js",
|
||||
filename: "[name].bundle.js"
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: `${__dirname}/build/template/index.template.html`,
|
||||
hash: true,
|
||||
filename: `../index.html`
|
||||
})
|
||||
]
|
||||
});
|
63
src/client/web/webpack.common.js
Normal file
63
src/client/web/webpack.common.js
Normal file
|
@ -0,0 +1,63 @@
|
|||
// const webpack = require("webpack");
|
||||
// const CleanWebpackPlugin = require("clean-webpack-plugin");
|
||||
// const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
|
||||
const path = require("path");
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
|
||||
.BundleAnalyzerPlugin;
|
||||
|
||||
module.exports = {
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts|tsx$/,
|
||||
loader: "ts-loader",
|
||||
include: [path.resolve(__dirname, "src")],
|
||||
exclude: /\.test\.(ts|tsx)$/
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
"style-loader",
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
url: false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".tsx", ".js", ".json"]
|
||||
},
|
||||
plugins: [
|
||||
// new BundleAnalyzerPlugin()
|
||||
],
|
||||
externals: {
|
||||
react: "React",
|
||||
"react-dom": "ReactDOM",
|
||||
immutable: "Immutable"
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [new TerserPlugin()],
|
||||
splitChunks: {
|
||||
chunks: "all",
|
||||
automaticNameDelimiter: ".",
|
||||
cacheGroups: {
|
||||
default: {
|
||||
name: "main",
|
||||
filename: "[name].bundle.js"
|
||||
},
|
||||
commons: {
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
name: "vendors",
|
||||
chunks: "all",
|
||||
minChunks: 2,
|
||||
reuseExistingChunk: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
16
src/client/web/webpack.dev.js
Normal file
16
src/client/web/webpack.dev.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
const merge = require("webpack-merge");
|
||||
const common = require("./webpack.common.js");
|
||||
|
||||
module.exports = merge(common, {
|
||||
mode: "development",
|
||||
devtool: "inline-source-map",
|
||||
// entry: {
|
||||
// api_test: "./libs/test/api_test"
|
||||
// },
|
||||
watchOptions: {
|
||||
aggregateTimeout: 1000,
|
||||
poll: 1000,
|
||||
ignored: /node_modules/
|
||||
},
|
||||
plugins: []
|
||||
});
|
8
src/client/web/webpack.prod.js
Normal file
8
src/client/web/webpack.prod.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
// const webpack = require("webpack");
|
||||
const merge = require("webpack-merge");
|
||||
|
||||
const common = require("./webpack.common.js");
|
||||
|
||||
module.exports = merge(common, {
|
||||
mode: "production"
|
||||
});
|
5874
src/client/web/yarn.lock
Normal file
5874
src/client/web/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
|
@ -135,7 +135,7 @@ func (fs *LocalFS) Remove(entryPath string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(fullpath)
|
||||
return os.RemoveAll(fullpath)
|
||||
}
|
||||
|
||||
func (fs *LocalFS) Rename(oldpath, newpath string) error {
|
||||
|
|
|
@ -2,6 +2,7 @@ package fileshdr
|
|||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
@ -32,6 +33,8 @@ var (
|
|||
rangeHeader = "Range"
|
||||
acceptRangeHeader = "Accept-Range"
|
||||
ifRangeHeader = "If-Range"
|
||||
keepAliveHeader = "Keep-Alive"
|
||||
connectionHeader = "Connection"
|
||||
)
|
||||
|
||||
type FileHandlers struct {
|
||||
|
@ -73,17 +76,22 @@ func (h *FileHandlers) NewAutoLocker(c *gin.Context, key string) *AutoLocker {
|
|||
func (lk *AutoLocker) Exec(handler func()) {
|
||||
var err error
|
||||
kv := lk.h.deps.KV()
|
||||
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
fmt.Println(p)
|
||||
}
|
||||
if err = kv.Unlock(lk.key); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err = kv.TryLock(lk.key); err != nil {
|
||||
lk.c.JSON(q.Resp(500))
|
||||
lk.c.JSON(q.ErrResp(lk.c, 500, errors.New("fail to lock the file")))
|
||||
return
|
||||
}
|
||||
|
||||
handler()
|
||||
|
||||
if err = kv.Unlock(lk.key); err != nil {
|
||||
// TODO: use logger
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateReq struct {
|
||||
|
@ -118,9 +126,9 @@ func (h *FileHandlers) Create(c *gin.Context) {
|
|||
c.JSON(q.ErrResp(c, 500, err))
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
c.JSON(q.Resp(200))
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FileHandlers) Delete(c *gin.Context) {
|
||||
|
@ -255,12 +263,21 @@ func (h *FileHandlers) UploadChunk(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
wrote, err := h.deps.FS().WriteAt(tmpFilePath, []byte(req.Content), req.Offset)
|
||||
content, err := base64.StdEncoding.DecodeString(req.Content)
|
||||
if err != nil {
|
||||
c.JSON(q.ErrResp(c, 500, err))
|
||||
return
|
||||
}
|
||||
err = h.uploadMgr.IncreUploaded(tmpFilePath, int64(wrote))
|
||||
|
||||
fmt.Println("length", len([]byte(content)))
|
||||
|
||||
wrote, err := h.deps.FS().WriteAt(tmpFilePath, []byte(content), req.Offset)
|
||||
if err != nil {
|
||||
c.JSON(q.ErrResp(c, 500, err))
|
||||
return
|
||||
}
|
||||
|
||||
err = h.uploadMgr.SetUploaded(tmpFilePath, req.Offset+int64(wrote))
|
||||
if err != nil {
|
||||
c.JSON(q.ErrResp(c, 500, err))
|
||||
return
|
||||
|
@ -308,7 +325,11 @@ func (h *FileHandlers) UploadStatus(c *gin.Context) {
|
|||
locker.Exec(func() {
|
||||
_, fileSize, uploaded, err := h.uploadMgr.GetInfo(tmpFilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(q.ErrResp(c, 404, err))
|
||||
} else {
|
||||
c.JSON(q.ErrResp(c, 500, err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -337,7 +358,7 @@ func (h *FileHandlers) Download(c *gin.Context) {
|
|||
info, err := h.deps.FS().Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(q.ErrResp(c, 400, os.ErrNotExist))
|
||||
c.JSON(q.ErrResp(c, 404, os.ErrNotExist))
|
||||
} else {
|
||||
c.JSON(q.ErrResp(c, 500, err))
|
||||
}
|
||||
|
|
|
@ -37,17 +37,13 @@ func (um *UploadMgr) AddInfo(fileName, tmpName string, fileSize int64, isDir boo
|
|||
return um.kv.SetString(infoKey(tmpName, filePathKey), fileName)
|
||||
}
|
||||
|
||||
func (um *UploadMgr) IncreUploaded(fileName string, newUploaded int64) error {
|
||||
func (um *UploadMgr) SetUploaded(fileName string, newUploaded int64) error {
|
||||
fileSize, ok := um.kv.GetInt64(infoKey(fileName, fileSizeKey))
|
||||
if !ok {
|
||||
return fmt.Errorf("file size %s not found", fileName)
|
||||
}
|
||||
preUploaded, ok := um.kv.GetInt64(infoKey(fileName, uploadedKey))
|
||||
if !ok {
|
||||
return fmt.Errorf("file uploaded %s not found", fileName)
|
||||
}
|
||||
if newUploaded+preUploaded <= fileSize {
|
||||
um.kv.SetInt64(infoKey(fileName, uploadedKey), newUploaded+preUploaded)
|
||||
if newUploaded <= fileSize {
|
||||
um.kv.SetInt64(infoKey(fileName, uploadedKey), newUploaded)
|
||||
return nil
|
||||
}
|
||||
return errors.New("uploaded is greater than file size")
|
||||
|
|
|
@ -89,6 +89,15 @@ type LoginReq struct {
|
|||
Pwd string `json:"pwd"`
|
||||
}
|
||||
|
||||
func (h *SimpleUserHandlers) checkPwd(user, pwd string) error {
|
||||
expectedHash, ok := h.deps.KV().GetStringIn(UsersNs, user)
|
||||
if !ok {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
|
||||
return bcrypt.CompareHashAndPassword([]byte(expectedHash), []byte(pwd))
|
||||
}
|
||||
|
||||
func (h *SimpleUserHandlers) Login(c *gin.Context) {
|
||||
req := &LoginReq{}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
@ -96,15 +105,8 @@ func (h *SimpleUserHandlers) Login(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
expectedHash, ok := h.deps.KV().GetStringIn(UsersNs, req.User)
|
||||
if !ok {
|
||||
c.JSON(q.ErrResp(c, 500, ErrInvalidConfig))
|
||||
return
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(expectedHash), []byte(req.Pwd))
|
||||
if err != nil {
|
||||
c.JSON(q.ErrResp(c, 401, err))
|
||||
if err := h.checkPwd(req.User, req.Pwd); err != nil {
|
||||
c.JSON(q.ErrResp(c, 500, err))
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -124,32 +126,30 @@ func (h *SimpleUserHandlers) Login(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
hostname := h.cfg.GrabString("Server.Host")
|
||||
secure := h.cfg.GrabBool("Users.CookieSecure")
|
||||
httpOnly := h.cfg.GrabBool("Users.CookieHttpOnly")
|
||||
c.SetCookie(TokenCookie, token, ttl, "/", hostname, secure, httpOnly)
|
||||
c.SetCookie(TokenCookie, token, ttl, "/", "", secure, httpOnly)
|
||||
|
||||
c.JSON(q.Resp(200))
|
||||
}
|
||||
|
||||
type LogoutReq struct {
|
||||
User string `json:"user"`
|
||||
}
|
||||
|
||||
func (h *SimpleUserHandlers) Logout(c *gin.Context) {
|
||||
req := &LogoutReq{}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(q.ErrResp(c, 500, err))
|
||||
return
|
||||
}
|
||||
|
||||
// token alreay verified in the authn middleware
|
||||
c.SetCookie(TokenCookie, "", 0, "/", "nohost", false, true)
|
||||
secure := h.cfg.GrabBool("Users.CookieSecure")
|
||||
httpOnly := h.cfg.GrabBool("Users.CookieHttpOnly")
|
||||
c.SetCookie(TokenCookie, "", 0, "/", "", secure, httpOnly)
|
||||
c.JSON(q.Resp(200))
|
||||
}
|
||||
|
||||
func (h *SimpleUserHandlers) IsAuthed(c *gin.Context) {
|
||||
// token alreay verified in the authn middleware
|
||||
c.JSON(q.Resp(200))
|
||||
}
|
||||
|
||||
type SetPwdReq struct {
|
||||
User string `json:"user"`
|
||||
OldPwd string `json:"oldPwd"`
|
||||
NewPwd string `json:"newPwd"`
|
||||
}
|
||||
|
@ -159,15 +159,24 @@ func (h *SimpleUserHandlers) SetPwd(c *gin.Context) {
|
|||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(q.ErrResp(c, 400, err))
|
||||
return
|
||||
} else if req.OldPwd == req.NewPwd {
|
||||
c.JSON(q.ErrResp(c, 400, errors.New("password is not updated")))
|
||||
return
|
||||
}
|
||||
|
||||
expectedHash, ok := h.deps.KV().GetStringIn(UsersNs, req.User)
|
||||
claims, err := h.getUserInfo(c)
|
||||
if err != nil {
|
||||
c.JSON(q.ErrResp(c, 401, err))
|
||||
return
|
||||
}
|
||||
|
||||
expectedHash, ok := h.deps.KV().GetStringIn(UsersNs, claims[UserParam])
|
||||
if !ok {
|
||||
c.JSON(q.ErrResp(c, 500, ErrInvalidConfig))
|
||||
return
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(expectedHash), []byte(req.OldPwd))
|
||||
err = bcrypt.CompareHashAndPassword([]byte(expectedHash), []byte(req.OldPwd))
|
||||
if err != nil {
|
||||
c.JSON(q.ErrResp(c, 401, ErrInvalidUser))
|
||||
return
|
||||
|
@ -178,7 +187,7 @@ func (h *SimpleUserHandlers) SetPwd(c *gin.Context) {
|
|||
c.JSON(q.ErrResp(c, 500, errors.New("fail to set password")))
|
||||
return
|
||||
}
|
||||
err = h.deps.KV().SetStringIn(UsersNs, req.User, string(newHash))
|
||||
err = h.deps.KV().SetStringIn(UsersNs, claims[UserParam], string(newHash))
|
||||
if err != nil {
|
||||
c.JSON(q.ErrResp(c, 500, ErrInvalidConfig))
|
||||
return
|
||||
|
@ -186,3 +195,25 @@ func (h *SimpleUserHandlers) SetPwd(c *gin.Context) {
|
|||
|
||||
c.JSON(q.Resp(200))
|
||||
}
|
||||
|
||||
func (h *SimpleUserHandlers) getUserInfo(c *gin.Context) (map[string]string, error) {
|
||||
tokenStr, err := c.Cookie(TokenCookie)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, err := h.deps.Token().FromToken(
|
||||
tokenStr,
|
||||
map[string]string{
|
||||
UserParam: "",
|
||||
RoleParam: "",
|
||||
ExpireParam: "",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if claims[UserParam] == "" {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
|
|
@ -15,6 +15,13 @@ var exposedAPIs = map[string]bool{
|
|||
"Health-fm": true,
|
||||
}
|
||||
|
||||
var publicRootPath = "/"
|
||||
var publicStaticPath = "/static"
|
||||
|
||||
func IsPublicPath(accessPath string) bool {
|
||||
return accessPath == publicRootPath || strings.HasPrefix(accessPath, publicStaticPath)
|
||||
}
|
||||
|
||||
func GetHandlerName(fullname string) (string, error) {
|
||||
parts := strings.Split(fullname, ".")
|
||||
if len(parts) == 0 {
|
||||
|
@ -30,13 +37,13 @@ func (h *SimpleUserHandlers) Auth() gin.HandlerFunc {
|
|||
c.JSON(q.ErrResp(c, 401, err))
|
||||
return
|
||||
}
|
||||
accessPath := c.Request.URL.String()
|
||||
|
||||
// TODO: may also check the path
|
||||
enableAuth := h.cfg.GrabBool("Users.EnableAuth")
|
||||
if enableAuth && !exposedAPIs[handlerName] {
|
||||
if enableAuth && !exposedAPIs[handlerName] && !IsPublicPath(accessPath) {
|
||||
token, err := c.Cookie(TokenCookie)
|
||||
if err != nil {
|
||||
c.JSON(q.ErrResp(c, 401, err))
|
||||
c.AbortWithStatusJSON(q.ErrResp(c, 401, err))
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -48,20 +55,20 @@ func (h *SimpleUserHandlers) Auth() gin.HandlerFunc {
|
|||
|
||||
_, err = h.deps.Token().FromToken(token, claims)
|
||||
if err != nil {
|
||||
c.JSON(q.ErrResp(c, 401, err))
|
||||
c.AbortWithStatusJSON(q.ErrResp(c, 401, err))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
expire, err := strconv.ParseInt(claims[ExpireParam], 10, 64)
|
||||
if err != nil || expire <= now {
|
||||
c.JSON(q.ErrResp(c, 401, err))
|
||||
c.AbortWithStatusJSON(q.ErrResp(c, 401, err))
|
||||
return
|
||||
}
|
||||
|
||||
// visitor is only allowed to download
|
||||
if claims[RoleParam] != AdminRole && handlerName != "Download-fm" {
|
||||
c.JSON(q.Resp(401))
|
||||
c.AbortWithStatusJSON(q.ErrResp(c, 401, errors.New("not allowed")))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ func DefaultConfig() (string, error) {
|
|||
OpenTTL: 60, // 1 min
|
||||
},
|
||||
Users: &UsersCfg{
|
||||
EnableAuth: false,
|
||||
EnableAuth: true,
|
||||
DefaultAdmin: "",
|
||||
DefaultAdminPwd: "",
|
||||
CookieTTL: 3600 * 24 * 7, // 1 week
|
||||
|
@ -61,10 +61,10 @@ func DefaultConfig() (string, error) {
|
|||
},
|
||||
Server: &ServerCfg{
|
||||
Debug: false,
|
||||
Host: "127.0.0.1",
|
||||
Host: "0.0.0.0",
|
||||
Port: 8888,
|
||||
ReadTimeout: 2000,
|
||||
WriteTimeout: 2000,
|
||||
WriteTimeout: 1000 * 3600 * 24, // 1 day
|
||||
MaxHeaderBytes: 512,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -124,7 +124,7 @@ func initHandlers(router *gin.Engine, cfg gocfg.ICfg, deps *depidx.Deps) (*gin.E
|
|||
// middleware
|
||||
router.Use(userHdrs.Auth())
|
||||
// tmp static server
|
||||
router.Use(static.Serve("/", static.LocalFile("../static", false)))
|
||||
router.Use(static.Serve("/", static.LocalFile("../public", false)))
|
||||
|
||||
// handler
|
||||
v1 := router.Group("/v1")
|
||||
|
@ -132,6 +132,7 @@ func initHandlers(router *gin.Engine, cfg gocfg.ICfg, deps *depidx.Deps) (*gin.E
|
|||
usersAPI := v1.Group("/users")
|
||||
usersAPI.POST("/login", userHdrs.Login)
|
||||
usersAPI.POST("/logout", userHdrs.Logout)
|
||||
usersAPI.GET("/isauthed", userHdrs.IsAuthed)
|
||||
usersAPI.PATCH("/pwd", userHdrs.SetPwd)
|
||||
|
||||
filesAPI := v1.Group("/fs")
|
||||
|
|
|
@ -2,6 +2,7 @@ package server
|
|||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
|
@ -60,7 +61,8 @@ func TestFileHandlers(t *testing.T) {
|
|||
return false
|
||||
}
|
||||
|
||||
res, _, errs = cl.UploadChunk(filePath, content, 0)
|
||||
base64Content := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
res, _, errs = cl.UploadChunk(filePath, base64Content, 0)
|
||||
if len(errs) > 0 {
|
||||
t.Error(errs)
|
||||
return false
|
||||
|
@ -172,7 +174,9 @@ func TestFileHandlers(t *testing.T) {
|
|||
right = len(contentBytes)
|
||||
}
|
||||
|
||||
res, _, errs = cl.UploadChunk(filePath, string(contentBytes[i:right]), int64(i))
|
||||
chunk := contentBytes[i:right]
|
||||
chunkBase64 := base64.StdEncoding.EncodeToString(chunk)
|
||||
res, _, errs = cl.UploadChunk(filePath, chunkBase64, int64(i))
|
||||
i = right
|
||||
if len(errs) > 0 {
|
||||
t.Fatal(errs)
|
||||
|
@ -193,6 +197,11 @@ func TestFileHandlers(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
err = fs.Sync()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// check uploaded file
|
||||
fsFilePath := filepath.Join(fileshdr.FsDir, filePath)
|
||||
info, err = fs.Stat(fsFilePath)
|
||||
|
@ -245,6 +254,11 @@ func TestFileHandlers(t *testing.T) {
|
|||
assertUploadOK(t, filePath, content)
|
||||
}
|
||||
|
||||
err = fs.Sync()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, lResp, errs := cl.List(dirPath)
|
||||
if len(errs) > 0 {
|
||||
t.Fatal(errs)
|
||||
|
@ -292,6 +306,11 @@ func TestFileHandlers(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
err = fs.Sync()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, lResp, errs := cl.List(dstDir)
|
||||
if len(errs) > 0 {
|
||||
t.Fatal(errs)
|
||||
|
|
|
@ -54,14 +54,14 @@ func TestSingleUserHandlers(t *testing.T) {
|
|||
|
||||
token := client.GetCookie(resp.Cookies(), su.TokenCookie)
|
||||
|
||||
resp, _, errs = suCl.SetPwd(adminName, adminPwd, adminNewPwd, token)
|
||||
resp, _, errs = suCl.SetPwd(adminPwd, adminNewPwd, token)
|
||||
if len(errs) > 0 {
|
||||
t.Fatal(errs)
|
||||
} else if resp.StatusCode != 200 {
|
||||
t.Fatal(resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _, errs = suCl.Logout(adminName, token)
|
||||
resp, _, errs = suCl.Logout(token)
|
||||
if len(errs) > 0 {
|
||||
t.Fatal(errs)
|
||||
} else if resp.StatusCode != 200 {
|
||||
|
|
94
src/static/index.html
Normal file
94
src/static/index.html
Normal file
|
@ -0,0 +1,94 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Quickshare</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="initial-scale=1.0, maximum-scale=1, minimum-scale=1, user-scalable=no,uc-fitscreen=yes"
|
||||
/>
|
||||
<meta class="chrome-color" name="theme-color" content="black" />
|
||||
<script src="/static/js/react.development.js?v=16.8.6"></script>
|
||||
<script src="/static/js/react-dom.development.js?v=16.8.6"></script>
|
||||
<script src="/static/js/immutable.min.js?v=4.0.0-rc.12"></script>
|
||||
<!-- <link
|
||||
rel="apple-touch-icon"
|
||||
sizes="57x57"
|
||||
href="/static/fav/apple-icon-57x57.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="60x60"
|
||||
href="/static/fav/apple-icon-60x60.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="72x72"
|
||||
href="/static/fav/apple-icon-72x72.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="76x76"
|
||||
href="/static/fav/apple-icon-76x76.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="114x114"
|
||||
href="/static/fav/apple-icon-114x114.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="120x120"
|
||||
href="/static/fav/apple-icon-120x120.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="144x144"
|
||||
href="/static/fav/apple-icon-144x144.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="152x152"
|
||||
href="/static/fav/apple-icon-152x152.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/static/fav/apple-icon-180x180.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="192x192"
|
||||
href="/static/fav/android-icon-192x192.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/static/fav/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="96x96"
|
||||
href="/static/fav/favicon-96x96.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/static/fav/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/static/fav/manifest.json" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta
|
||||
name="msapplication-TileImage"
|
||||
content="/static/fav/ms-icon-144x144.png"
|
||||
/> -->
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="content"><div id="mount"></div></div>
|
||||
<script src="main.bundle.js?4fa055bbbe4e223c0472"></script></body>
|
||||
</html>
|
|
@ -2869,6 +2869,11 @@ filesize@^3.6.1:
|
|||
resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317"
|
||||
integrity sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg==
|
||||
|
||||
filesize@^6.1.0:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.1.0.tgz#e81bdaa780e2451d714d71c0d7a4f3238d37ad00"
|
||||
integrity sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg==
|
||||
|
||||
fill-range@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue