Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baefaf51cc | ||
|
|
d48d332c83 | ||
|
|
590ed9ce73 | ||
|
|
ea5be502b3 | ||
|
|
e50cc6ee28 | ||
|
|
00639b7bc2 | ||
|
|
e7b4111d2b | ||
|
|
643f73c752 | ||
|
|
e6665a3977 | ||
|
|
9524a234cf | ||
|
|
af2c13a8b4 | ||
|
|
ae71d36c7f | ||
|
|
2b196a95a8 |
|
|
@ -9,10 +9,9 @@ services:
|
||||||
# Update 'VARIANT' to pick a version of PHP version: 8, 8.1, 8.0, 7, 7.4
|
# Update 'VARIANT' to pick a version of PHP version: 8, 8.1, 8.0, 7, 7.4
|
||||||
# Append -bullseye or -buster to pin to an OS version.
|
# Append -bullseye or -buster to pin to an OS version.
|
||||||
# Use -bullseye variants on local arm64/Apple Silicon.
|
# Use -bullseye variants on local arm64/Apple Silicon.
|
||||||
VARIANT: "8-bullseye"
|
VARIANT: 8-bullseye
|
||||||
# Optional Node.js version
|
# Optional Node.js version
|
||||||
NODE_VERSION: "lts/*"
|
NODE_VERSION: 'lts/*'
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/workspace:cached
|
- ..:/workspace:cached
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ root = true
|
||||||
[*]
|
[*]
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
indent_size = 2
|
indent_size = 4
|
||||||
indent_style = space
|
indent_style = tab
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
[*.{php,md,ps1,Dockerfile}]
|
[*.{json,yaml,yml}]
|
||||||
indent_size = 4
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
public/
|
|
||||||
vendor/
|
|
||||||
coverage/
|
|
||||||
plugins/
|
|
||||||
node_modules/
|
|
||||||
*.d.ts
|
|
||||||
resources/assets/tests/__mocks__/
|
|
||||||
resources/assets/tests/ts-shims/
|
|
||||||
resources/assets/tests/*.ts
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
root: true
|
|
||||||
parser: '@typescript-eslint/parser'
|
|
||||||
parserOptions:
|
|
||||||
project: tsconfig.eslint.json
|
|
||||||
plugins:
|
|
||||||
- '@typescript-eslint/eslint-plugin'
|
|
||||||
extends:
|
|
||||||
- eslint:recommended
|
|
||||||
- plugin:@typescript-eslint/recommended
|
|
||||||
- plugin:@typescript-eslint/recommended-requiring-type-checking
|
|
||||||
- plugin:react-hooks/recommended
|
|
||||||
rules:
|
|
||||||
prefer-const: error
|
|
||||||
'@typescript-eslint/no-unsafe-assignment': off
|
|
||||||
'@typescript-eslint/no-unsafe-member-access': off
|
|
||||||
'@typescript-eslint/no-unsafe-return': off
|
|
||||||
'@typescript-eslint/no-unused-vars': off
|
|
||||||
'@typescript-eslint/explicit-module-boundary-types': off
|
|
||||||
'@typescript-eslint/no-explicit-any': off
|
|
||||||
'@typescript-eslint/ban-ts-comment': off
|
|
||||||
'@typescript-eslint/no-non-null-assertion': off
|
|
||||||
'@typescript-eslint/no-floating-promises': off
|
|
||||||
'@typescript-eslint/no-misused-promises':
|
|
||||||
- off
|
|
||||||
- checksVoidReturn: false
|
|
||||||
'@typescript-eslint/unbound-method': off
|
|
||||||
'@typescript-eslint/restrict-template-expressions': off
|
|
||||||
27
.gitpod.yml
27
.gitpod.yml
|
|
@ -13,28 +13,15 @@ tasks:
|
||||||
php artisan serve --host=0.0.0.0
|
php artisan serve --host=0.0.0.0
|
||||||
- command: gp ports await 8080 && gp preview $(gp url 8000)
|
- command: gp ports await 8080 && gp preview $(gp url 8000)
|
||||||
|
|
||||||
github:
|
|
||||||
prebuilds:
|
|
||||||
# enable for the master/default branch (defaults to true)
|
|
||||||
master: true
|
|
||||||
# enable for all branches in this repo (defaults to false)
|
|
||||||
branches: false
|
|
||||||
# enable for pull requests coming from this repo (defaults to true)
|
|
||||||
pullRequests: true
|
|
||||||
# add a check to pull requests (defaults to true)
|
|
||||||
addCheck: true
|
|
||||||
# add a "Review in Gitpod" button as a comment to pull requests (defaults to false)
|
|
||||||
addComment: false
|
|
||||||
|
|
||||||
vscode:
|
vscode:
|
||||||
extensions:
|
extensions:
|
||||||
- 'editorconfig.editorconfig'
|
- editorconfig.editorconfig
|
||||||
- 'eamodio.gitlens'
|
- eamodio.gitlens
|
||||||
- 'bmewburn.vscode-intelephense-client'
|
- bmewburn.vscode-intelephense-client
|
||||||
- 'esbenp.prettier-vscode'
|
- esbenp.prettier-vscode
|
||||||
- 'jpoissonnier.vscode-styled-components'
|
- jpoissonnier.vscode-styled-components
|
||||||
- 'mblode.twig-language-2'
|
- mblode.twig-language-2
|
||||||
- 'felixfbecker.php-debug'
|
- felixfbecker.php-debug
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- port: 8080
|
- port: 8080
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
yarn pretty-quick --staged
|
|
||||||
72
.vscode/launch.json
vendored
72
.vscode/launch.json
vendored
|
|
@ -1,38 +1,38 @@
|
||||||
{
|
{
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Launch Jest Tests",
|
"name": "Launch Jest Tests",
|
||||||
"program": "${workspaceFolder}/node_modules/.bin/jest",
|
"program": "${workspaceFolder}/node_modules/.bin/jest",
|
||||||
"args": ["${file}"],
|
"args": ["${file}"],
|
||||||
"internalConsoleOptions": "openOnSessionStart",
|
"internalConsoleOptions": "openOnSessionStart",
|
||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"<node_internals>/**"
|
"<node_internals>/**"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "php",
|
"type": "php",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Launch with XDebug",
|
"name": "Launch with XDebug",
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"**/vendor/**/*.php"
|
"**/vendor/**/*.php"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "firefox",
|
"type": "firefox",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"reAttach": true,
|
"reAttach": true,
|
||||||
"name": "Launch with Firefox Debugger",
|
"name": "Launch with Firefox Debugger",
|
||||||
"url": "http://localhost/",
|
"url": "http://localhost/",
|
||||||
"webRoot": "${workspaceFolder}",
|
"webRoot": "${workspaceFolder}",
|
||||||
"pathMappings": [
|
"pathMappings": [
|
||||||
{
|
{
|
||||||
"url": "webpack:///",
|
"url": "webpack:///",
|
||||||
"path": "${workspaceFolder}/"
|
"path": "${workspaceFolder}/"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ class UserMenuComposer
|
||||||
['label' => trans('general.admin-panel'), 'link' => route('admin.view')],
|
['label' => trans('general.admin-panel'), 'link' => route('admin.view')],
|
||||||
['label' => trans('general.user-manage'), 'link' => route('admin.users.view')],
|
['label' => trans('general.user-manage'), 'link' => route('admin.users.view')],
|
||||||
['label' => trans('general.report-manage'), 'link' => route('admin.reports.view')],
|
['label' => trans('general.report-manage'), 'link' => route('admin.reports.view')],
|
||||||
['label' => 'Web CLI', 'link' => '#launch-cli'],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
$menuItems = $this->filter->apply('user_menu', $menuItems, [$user]);
|
$menuItems = $this->filter->apply('user_menu', $menuItems, [$user]);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
"lorisleiva/laravel-search-string": "^1.0",
|
"lorisleiva/laravel-search-string": "^1.0",
|
||||||
"nesbot/carbon": "^2.0",
|
"nesbot/carbon": "^2.0",
|
||||||
"nunomaduro/collision": "^7.0",
|
"nunomaduro/collision": "^7.0",
|
||||||
"rcrowe/twigbridge": "^0.14",
|
"rcrowe/twigbridge": "dev-blessing",
|
||||||
"spatie/laravel-translation-loader": "^2.7",
|
"spatie/laravel-translation-loader": "^2.7",
|
||||||
"symfony/process": "^6.0",
|
"symfony/process": "^6.0",
|
||||||
"symfony/yaml": "^5.0",
|
"symfony/yaml": "^5.0",
|
||||||
|
|
@ -81,10 +81,14 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"repositories": {
|
"repositories": [
|
||||||
"packagist": {
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "https://github.com/bs-community/TwigBridge"
|
||||||
|
},
|
||||||
|
{
|
||||||
"type": "composer",
|
"type": "composer",
|
||||||
"url": "https://packagist.org/"
|
"url": "https://packagist.org/"
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4743
composer.lock
generated
4743
composer.lock
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -123,6 +123,7 @@ return [
|
||||||
|
|
||||||
// 'TwigBridge\Extension\Laravel\Form',
|
// 'TwigBridge\Extension\Laravel\Form',
|
||||||
// 'TwigBridge\Extension\Laravel\Html',
|
// 'TwigBridge\Extension\Laravel\Html',
|
||||||
|
'TwigBridge\Extension\Laravel\Vite',
|
||||||
// 'TwigBridge\Extension\Laravel\Legacy\Facades',
|
// 'TwigBridge\Extension\Laravel\Legacy\Facades',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
@ -153,7 +154,8 @@ return [
|
||||||
| in order to be marked as safe.
|
| in order to be marked as safe.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'facades' => [],
|
'facades' => [
|
||||||
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
11
eslint.config.js
Normal file
11
eslint.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import {configBuilder} from '@mochaa/eslintrc';
|
||||||
|
|
||||||
|
export default configBuilder({
|
||||||
|
ignores: [
|
||||||
|
'public/',
|
||||||
|
'vendor/',
|
||||||
|
'vendor/',
|
||||||
|
'plugins/',
|
||||||
|
'storage/',
|
||||||
|
],
|
||||||
|
});
|
||||||
198
package.json
198
package.json
|
|
@ -1,162 +1,90 @@
|
||||||
{
|
{
|
||||||
"name": "blessing-skin-server",
|
"name": "blessing-skin-server",
|
||||||
|
"type": "module",
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
||||||
"description": "A web application brings your custom skins back in offline Minecraft servers.",
|
"description": "A web application brings your custom skins back in offline Minecraft servers.",
|
||||||
|
"author": "printempw",
|
||||||
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/bs-community/blessing-skin-server"
|
"url": "https://github.com/bs-community/blessing-skin-server"
|
||||||
},
|
},
|
||||||
"author": "printempw",
|
|
||||||
"license": "MIT",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack serve",
|
"build": "vite build",
|
||||||
"build": "webpack --env production --progress",
|
|
||||||
"lint": "eslint --ext=ts -f=beauty .",
|
|
||||||
"fmt": "prettier --write resources/assets tools webpack.config.ts",
|
|
||||||
"fmt:check": "prettier --check resources/assets tools webpack.config.ts",
|
|
||||||
"type:check": "tsc -p . --noEmit && tsc -p ./resources/assets/tests --noEmit",
|
|
||||||
"test": "jest",
|
|
||||||
"build:urls": "ts-node tools/generateUrls.ts",
|
"build:urls": "ts-node tools/generateUrls.ts",
|
||||||
"prepare": "husky install"
|
"dev": "vite",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"Firefox ESR",
|
||||||
|
"iOS >= 12.5",
|
||||||
|
"Chrome >= 87"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.0.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.0.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@fortawesome/fontawesome-free": "^6.3.0",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@hot-loader/react-dom": "^17.0.0",
|
"@tweenjs/tween.js": "^25.0.0",
|
||||||
"@tweenjs/tween.js": "^18.5.0",
|
"admin-lte": "4.0.0-beta3",
|
||||||
"admin-lte": "^3.2.0",
|
"bootstrap": "^5.3.3",
|
||||||
"blessing-skin-shell": "^0.3.4",
|
"clsx": "^2.1.1",
|
||||||
"bootstrap": "^4.6.1",
|
"downshift": "^9.0.8",
|
||||||
"cac": "6.6.1",
|
"echarts": "^5.6.0",
|
||||||
"cli-spinners": "^2.5.0",
|
"immer": "^10.1.1",
|
||||||
"clsx": "^1.1.1",
|
|
||||||
"echarts": "^5.1.2",
|
|
||||||
"events": "^3.2.0",
|
|
||||||
"immer": "^7.0.4",
|
|
||||||
"jquery": "^3.6.0",
|
"jquery": "^3.6.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash-es": "^4.0.8",
|
||||||
"nanoid": "^3.1.9",
|
"nanoid": "^5.0.9",
|
||||||
"prompts": "^2.4.0",
|
"prompts": "^2.4.0",
|
||||||
"react": "^17.0.1",
|
"react": "^18.0.0",
|
||||||
"react-autosuggest": "^10.0.2",
|
"react-dom": "^18.0.0",
|
||||||
"react-dom": "^17.0.1",
|
|
||||||
"react-draggable": "^4.4.2",
|
"react-draggable": "^4.4.2",
|
||||||
"react-hot-loader": "^4.12.21",
|
"react-loading-skeleton": "^3.5.0",
|
||||||
"react-loading-skeleton": "^2.1.1",
|
"react-use": "^17.6.0",
|
||||||
"react-use": "^17.4.0",
|
|
||||||
"reaptcha": "^1.7.2",
|
"reaptcha": "^1.7.2",
|
||||||
"rxjs": "^6.5.5",
|
"rxjs": "^7.8.1",
|
||||||
"skinview-utils": "^0.5.5",
|
"skinview-utils": "^0.7.1",
|
||||||
"skinview3d": "^3.0.0-alpha.1",
|
"skinview3d": "^3.1.0",
|
||||||
"spectre.css": "^0.5.8",
|
"spectre.css": "github:angular-package/spectre.css",
|
||||||
"use-immer": "^0.4.2",
|
"use-immer": "^0.11.0"
|
||||||
"xterm": "^4.6.0",
|
|
||||||
"xterm-addon-fit": "^0.4.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@gplane/tsconfig": "^4.2.0",
|
"@eslint-react/eslint-plugin": "^1.23.2",
|
||||||
"@testing-library/jest-dom": "^5.11.10",
|
"@mochaa/eslintrc": "^0.1.12",
|
||||||
"@testing-library/react": "^11.2.6",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@types/bootstrap": "^4.3.3",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@types/css-minimizer-webpack-plugin": "^1.1.0",
|
"@tsconfig/vite-react": "^3.4.0",
|
||||||
"@types/jest": "^26.0.23",
|
"@types/bootstrap": "^5.2.10",
|
||||||
"@types/jquery": "^3.5.13",
|
"@types/jquery": "^3.5.32",
|
||||||
"@types/js-yaml": "^3.12.4",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/lodash.debounce": "^4.0.6",
|
"@types/lodash-es": "^4.0.6",
|
||||||
"@types/mini-css-extract-plugin": "^1.2.1",
|
|
||||||
"@types/prompts": "^2.0.9",
|
"@types/prompts": "^2.0.9",
|
||||||
"@types/react": "^16.9.35",
|
"@types/react": "^18",
|
||||||
"@types/react-autosuggest": "^9.3.14",
|
"@types/react-dom": "^18",
|
||||||
"@types/react-dom": "^16.9.8",
|
|
||||||
"@types/tween.js": "^18.5.0",
|
"@types/tween.js": "^18.5.0",
|
||||||
"@types/webpack-dev-server": "^3.11.0",
|
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^3.6.0",
|
"autoprefixer": "^10.4.20",
|
||||||
"@typescript-eslint/parser": "^3.6.0",
|
"browserslist": "^4.24.4",
|
||||||
"autoprefixer": "^10.2.6",
|
"browserslist-to-esbuild": "^2.1.1",
|
||||||
"css-loader": "^5.2.6",
|
"eslint": "^9.18.0",
|
||||||
"css-minimizer-webpack-plugin": "^3.0.1",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint": "^7.4.0",
|
"eslint-plugin-react-refresh": "^0.4.18",
|
||||||
"eslint-formatter-beauty": "^3.0.0",
|
"js-yaml": "^4.1.0",
|
||||||
"eslint-plugin-react-hooks": "^4.3.0",
|
"laravel-vite-plugin": "^1.1.1",
|
||||||
"html-webpack-plugin": "^5.3.1",
|
"postcss": "^8.5.1",
|
||||||
"husky": "^7.0.4",
|
"sass": "^1.83.4",
|
||||||
"jest": "^27.0.4",
|
"typescript": "^5.7.3",
|
||||||
"jest-extended": "^0.11.5",
|
"vite": "^6.0.7",
|
||||||
"js-yaml": "^3.13.1",
|
"vitest": "^3.0.2"
|
||||||
"mini-css-extract-plugin": "^1.6.0",
|
|
||||||
"postcss": "^8.3.0",
|
|
||||||
"postcss-loader": "^5.3.0",
|
|
||||||
"prettier": "^2.3.0",
|
|
||||||
"pretty-quick": "^3.1.3",
|
|
||||||
"style-loader": "^2.0.0",
|
|
||||||
"ts-jest": "^27.0.2",
|
|
||||||
"ts-loader": "^9.2.2",
|
|
||||||
"ts-node": "^10.0.0",
|
|
||||||
"typescript": "^4.3.2",
|
|
||||||
"webpack": "^5.38.1",
|
|
||||||
"webpack-cli": "^4.7.0",
|
|
||||||
"webpack-dev-server": "^3.11.2"
|
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"kleur": "^4.1.3"
|
"kleur": "^4.1.3"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"postcss": {
|
||||||
"> 1%",
|
"plugins": {
|
||||||
"not dead",
|
"autoprefixer": {}
|
||||||
"not ie 11",
|
|
||||||
"Chrome > 52"
|
|
||||||
],
|
|
||||||
"prettier": {
|
|
||||||
"printWidth": 80,
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"tabWidth": 2
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"preset": "ts-jest",
|
|
||||||
"resetMocks": true,
|
|
||||||
"testEnvironment": "jsdom",
|
|
||||||
"moduleFileExtensions": [
|
|
||||||
"js",
|
|
||||||
"ts",
|
|
||||||
"tsx",
|
|
||||||
"json",
|
|
||||||
"node"
|
|
||||||
],
|
|
||||||
"moduleNameMapper": {
|
|
||||||
"\\.css$": "<rootDir>/resources/assets/tests/__mocks__/style.ts",
|
|
||||||
"\\.(png|webp)$": "<rootDir>/resources/assets/tests/__mocks__/file.ts",
|
|
||||||
"^@/(.*)$": "<rootDir>/resources/assets/src/$1"
|
|
||||||
},
|
|
||||||
"setupFilesAfterEnv": [
|
|
||||||
"<rootDir>/resources/assets/tests/setup.ts"
|
|
||||||
],
|
|
||||||
"coveragePathIgnorePatterns": [
|
|
||||||
"/node_modules/",
|
|
||||||
"<rootDir>/resources/assets/src/styles",
|
|
||||||
"<rootDir>/resources/assets/src/scripts/extra.ts",
|
|
||||||
"<rootDir>/resources/assets/src/scripts/urls.ts",
|
|
||||||
"<rootDir>/resources/assets/tests/setup",
|
|
||||||
"<rootDir>/resources/assets/tests/utils",
|
|
||||||
"<rootDir>/resources/assets/tests/scripts/cli/stdio"
|
|
||||||
],
|
|
||||||
"testMatch": [
|
|
||||||
"<rootDir>/resources/assets/tests/**/*.test.ts",
|
|
||||||
"<rootDir>/resources/assets/tests/**/*.test.tsx"
|
|
||||||
],
|
|
||||||
"testPathIgnorePatterns": [
|
|
||||||
"/node_modules/",
|
|
||||||
"<rootDir>/resources/assets/tests/(views|components)/.*\\.ts$"
|
|
||||||
],
|
|
||||||
"maxWorkers": "50%",
|
|
||||||
"globals": {
|
|
||||||
"ts-jest": {
|
|
||||||
"tsconfig": "<rootDir>/resources/assets/tests/tsconfig.json",
|
|
||||||
"isolatedModules": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
plugins: [
|
|
||||||
require('autoprefixer'),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
2
public/.gitignore
vendored
2
public/.gitignore
vendored
|
|
@ -1 +1,3 @@
|
||||||
app/
|
app/
|
||||||
|
build/
|
||||||
|
hot
|
||||||
|
|
|
||||||
6
resources/assets/src/app.css
Normal file
6
resources/assets/src/app.css
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
@import '@/styles/common.css';
|
||||||
|
@import 'admin-lte/src/scss/adminlte.scss';
|
||||||
|
@import '@fortawesome/fontawesome-free/scss/fontawesome.scss';
|
||||||
|
@import '@fortawesome/fontawesome-free/scss/regular.scss';
|
||||||
|
@import '@fortawesome/fontawesome-free/scss/solid.scss';
|
||||||
|
@import '@fortawesome/fontawesome-free/scss/brands.scss';
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
import React from 'react'
|
type AlertType = 'success' | 'info' | 'warning' | 'danger';
|
||||||
|
|
||||||
type AlertType = 'success' | 'info' | 'warning' | 'danger'
|
|
||||||
|
|
||||||
const icons = new Map<AlertType, string>([
|
const icons = new Map<AlertType, string>([
|
||||||
['success', 'check'],
|
['success', 'check'],
|
||||||
['info', 'info'],
|
['info', 'info'],
|
||||||
['warning', 'exclamation-triangle'],
|
['warning', 'exclamation-triangle'],
|
||||||
['danger', 'times-circle'],
|
['danger', 'times-circle'],
|
||||||
])
|
]);
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
type: AlertType
|
readonly type: AlertType;
|
||||||
}
|
readonly children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
const Alert: React.FC<Props> = (props) => {
|
const Alert: React.FC<Props> = ({type, children}) => {
|
||||||
const { type } = props
|
const icon = icons.get(type);
|
||||||
const icon = icons.get(type)
|
|
||||||
|
|
||||||
return props.children ? (
|
return children === ''
|
||||||
<div className={`alert alert-${type}`}>
|
? null
|
||||||
<i className={`icon fas fa-${icon}`}></i>
|
: (
|
||||||
{props.children}
|
<div className={`alert alert-${type}`}>
|
||||||
</div>
|
<i className={`icon fas fa-${icon}`}/>
|
||||||
) : null
|
{children}
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Alert
|
export default Alert;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import React from 'react'
|
type Props = {
|
||||||
|
readonly title?: string;
|
||||||
|
readonly onClick: React.MouseEventHandler<HTMLAnchorElement>;
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
const ButtonEdit: React.FC<Props> = ({title, onClick}) => (
|
||||||
title?: string
|
<a href='#' title={title} className='ml-2' onClick={onClick}>
|
||||||
onClick: React.MouseEventHandler<HTMLAnchorElement>
|
<i className='fas fa-edit'/>
|
||||||
}
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
const ButtonEdit: React.FC<Props> = (props) => (
|
export default ButtonEdit;
|
||||||
<a href="#" title={props.title} className="ml-2" onClick={props.onClick}>
|
|
||||||
<i className="fas fa-edit"></i>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default ButtonEdit
|
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,108 @@
|
||||||
/** @jsxImportSource @emotion/react */
|
import {emit, on} from '@/scripts/event';
|
||||||
import * as React from 'react'
|
import {t} from '@/scripts/i18n';
|
||||||
import Reaptcha from 'reaptcha'
|
import * as cssUtils from '@/styles/utils';
|
||||||
import { emit, on } from '@/scripts/event'
|
import React from 'react';
|
||||||
import { t } from '@/scripts/i18n'
|
import Reaptcha from 'reaptcha';
|
||||||
import * as cssUtils from '@/styles/utils'
|
|
||||||
|
|
||||||
const eventId = Symbol()
|
const eventId = Symbol('EventId');
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
value: string
|
value: string;
|
||||||
time: number
|
time: number;
|
||||||
sitekey: string
|
sitekey: string;
|
||||||
invisible: boolean
|
invisible: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
class Captcha extends React.Component<Record<string, unknown>, State> {
|
class Captcha extends React.Component<Record<string, unknown>, State> {
|
||||||
state: State
|
state: State;
|
||||||
ref: React.MutableRefObject<Reaptcha | null>
|
// eslint-disable-next-line ts/no-restricted-types
|
||||||
|
ref: React.RefObject<Reaptcha | null>;
|
||||||
|
|
||||||
constructor(props: Record<string, unknown>) {
|
constructor(props: Record<string, unknown>) {
|
||||||
super(props)
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
value: '',
|
value: '',
|
||||||
time: Date.now(),
|
time: Date.now(),
|
||||||
sitekey: blessing.extra.recaptcha,
|
sitekey: blessing.extra.recaptcha as string,
|
||||||
invisible: blessing.extra.invisible,
|
invisible: blessing.extra.invisible as boolean,
|
||||||
}
|
};
|
||||||
this.ref = React.createRef()
|
this.ref = React.createRef<Reaptcha>();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute = async () => {
|
// eslint-disable-next-line react/no-unused-class-component-members
|
||||||
const recaptcha = this.ref.current
|
async execute() {
|
||||||
if (recaptcha && this.state.invisible) {
|
const recaptcha = this.ref.current;
|
||||||
return new Promise<string>((resolve) => {
|
if (recaptcha && this.state.invisible) {
|
||||||
const off = on(eventId, (value: string) => {
|
return new Promise<string>(resolve => {
|
||||||
resolve(value)
|
const off = on(eventId, (value: string) => {
|
||||||
off()
|
resolve(value);
|
||||||
})
|
off();
|
||||||
recaptcha.execute()
|
});
|
||||||
})
|
void recaptcha.execute();
|
||||||
}
|
});
|
||||||
return this.state.value
|
}
|
||||||
}
|
|
||||||
|
|
||||||
reset = () => {
|
return this.state.value;
|
||||||
const recaptcha = this.ref.current
|
}
|
||||||
if (recaptcha) {
|
|
||||||
recaptcha.reset()
|
|
||||||
} else {
|
|
||||||
this.setState({ time: Date.now() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
// eslint-disable-next-line react/no-unused-class-component-members
|
||||||
this.setState({ value: event.target.value })
|
reset() {
|
||||||
}
|
const recaptcha = this.ref.current;
|
||||||
|
if (recaptcha) {
|
||||||
|
void recaptcha.reset();
|
||||||
|
} else {
|
||||||
|
this.setState({time: Date.now()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleVerify = (value: string) => {
|
handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
emit(eventId, value)
|
this.setState({value: event.target.value});
|
||||||
this.setState({ value })
|
};
|
||||||
}
|
|
||||||
|
|
||||||
handleRefresh = () => {
|
handleVerify = (value: string) => {
|
||||||
this.setState({ time: Date.now() })
|
emit(eventId, value);
|
||||||
}
|
this.setState({value});
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
handleRefresh = () => {
|
||||||
return this.state.sitekey ? (
|
this.setState({time: Date.now()});
|
||||||
<div className="mb-2">
|
};
|
||||||
<Reaptcha
|
|
||||||
ref={this.ref}
|
render() {
|
||||||
sitekey={this.state.sitekey}
|
return this.state.sitekey
|
||||||
size={this.state.invisible ? 'invisible' : 'normal'}
|
? (
|
||||||
onVerify={this.handleVerify}
|
<div className='mb-2'>
|
||||||
/>
|
<Reaptcha
|
||||||
</div>
|
ref={this.ref}
|
||||||
) : (
|
sitekey={this.state.sitekey}
|
||||||
<div className="d-flex">
|
size={this.state.invisible ? 'invisible' : 'normal'}
|
||||||
<div className="form-group mb-3 mr-2">
|
onVerify={this.handleVerify}
|
||||||
<input
|
/>
|
||||||
type="text"
|
</div>
|
||||||
className="form-control"
|
)
|
||||||
placeholder={t('auth.captcha')}
|
: (
|
||||||
required
|
<div className='d-flex'>
|
||||||
value={this.state.value}
|
<div className='form-group mb-3 mr-2'>
|
||||||
onChange={this.handleValueChange}
|
<input
|
||||||
/>
|
required
|
||||||
</div>
|
type='text'
|
||||||
<img
|
className='form-control'
|
||||||
src={`${blessing.base_url}/auth/captcha?v=${this.state.time}`}
|
placeholder={t('auth.captcha')}
|
||||||
alt={t('auth.captcha')}
|
value={this.state.value}
|
||||||
css={cssUtils.pointerCursor}
|
onChange={this.handleValueChange}
|
||||||
height={34}
|
/>
|
||||||
title={t('auth.change-captcha')}
|
</div>
|
||||||
onClick={this.handleRefresh}
|
<img
|
||||||
/>
|
src={`${blessing.base_url}/auth/captcha?v=${this.state.time}`}
|
||||||
</div>
|
alt={t('auth.captcha')}
|
||||||
)
|
css={cssUtils.pointerCursor}
|
||||||
}
|
height={34}
|
||||||
|
title={t('auth.change-captcha')}
|
||||||
|
onClick={this.handleRefresh}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Captcha
|
export default Captcha;
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
import React, { useState } from 'react'
|
import * as fetch from '@/scripts/net';
|
||||||
import * as fetch from '@/scripts/net'
|
import {useState} from 'react';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
initMode: boolean
|
readonly initMode: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
const DarkModeButton: React.FC<Props> = ({ initMode }) => {
|
const DarkModeButton: React.FC<Props> = ({initMode}) => {
|
||||||
const [darkMode, setDarkMode] = useState(initMode)
|
const [darkMode, setDarkMode] = useState(initMode);
|
||||||
|
|
||||||
const icon = darkMode ? 'moon' : 'sun'
|
const icon = darkMode ? 'moon' : 'sun';
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
setDarkMode((value) => !value)
|
setDarkMode(value => !value);
|
||||||
|
|
||||||
await fetch.put('/user/dark-mode')
|
await fetch.put('/user/dark-mode');
|
||||||
document.body.classList.toggle('dark-mode')
|
document.body.classList.toggle('dark-mode');
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a className="nav-link" href="#" role="button" onClick={handleClick}>
|
<a className='nav-link' href='#' role='button' onClick={handleClick}>
|
||||||
<i className={`fas fa-${icon}`}></i>
|
<i className={`fas fa-${icon}`}/>
|
||||||
</a>
|
</a>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default DarkModeButton
|
export default DarkModeButton;
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,67 @@
|
||||||
/** @jsxImportSource @emotion/react */
|
import {emit} from '@/scripts/event';
|
||||||
import React, { useState, useEffect } from 'react'
|
import {pointerCursor} from '@/styles/utils';
|
||||||
import Autosuggest from 'react-autosuggest'
|
import {css} from '@emotion/react';
|
||||||
import { css } from '@emotion/react'
|
import clsx from 'clsx';
|
||||||
import { emit } from '@/scripts/event'
|
import {useCombobox} from 'downshift';
|
||||||
import { pointerCursor } from '@/styles/utils'
|
import {useEffect, useState} from 'react';
|
||||||
|
|
||||||
const styles = css`
|
const styles = css`
|
||||||
.dropdown-menu li {
|
.dropdown-menu li {
|
||||||
${pointerCursor}
|
${pointerCursor}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
const domainNames = new Set(['qq.com', '163.com', 'gmail.com', 'hotmail.com'])
|
const domainNames = new Set(['qq.com', '163.com', 'gmail.com', 'hotmail.com']);
|
||||||
|
|
||||||
type Props = Omit<Autosuggest.InputProps<string>, 'onChange'> & {
|
type Props = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
||||||
onChange(value: string): void
|
onChange: (value: string) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const EmailSuggestion: React.FC<Props> = (props) => {
|
const EmailSuggestion: React.FC<Props> = props => {
|
||||||
const [suggestions, setSuggestions] = useState<string[]>([])
|
useEffect(() => {
|
||||||
|
emit('emailDomainsSuggestion', domainNames);
|
||||||
|
}, []);
|
||||||
|
const [inputItems, setInputItems] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
const {
|
||||||
emit('emailDomainsSuggestion', domainNames)
|
isOpen,
|
||||||
}, [])
|
getLabelProps,
|
||||||
|
getMenuProps,
|
||||||
|
getInputProps,
|
||||||
|
highlightedIndex,
|
||||||
|
getItemProps,
|
||||||
|
} = useCombobox({
|
||||||
|
items: inputItems,
|
||||||
|
onInputValueChange({inputValue: value}) {
|
||||||
|
setInputItems([...domainNames].map(name => `${value.split('@')[0]}@${name}`));
|
||||||
|
if (value.length === 0 || value.includes('@')) {
|
||||||
|
setInputItems([]);
|
||||||
|
}
|
||||||
|
|
||||||
const handleSuggestionsFetchRequested: Autosuggest.SuggestionsFetchRequested =
|
const {onChange} = props;
|
||||||
({ value }) => {
|
onChange(value);
|
||||||
const segments = value.split('@')
|
},
|
||||||
setSuggestions([...domainNames].map((name) => `${segments[0]}@${name}`))
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const handleSuggestionsClearRequested = () => {
|
return (
|
||||||
setSuggestions([])
|
<div>
|
||||||
}
|
<div className='input-group'>
|
||||||
|
<input className='form-control' {...{...props, onChange: undefined}} {...getInputProps()}/>
|
||||||
|
<div className='input-group-text' {...getLabelProps()}>
|
||||||
|
<i className='fas fa-envelope'/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='mb-3 dropdown' css={styles}>
|
||||||
|
<ul className={clsx('dropdown-menu', isOpen && inputItems.length > 0 && 'show')} {...getMenuProps()}>
|
||||||
|
{isOpen && inputItems.length > 0 && inputItems.map((item, index) => (
|
||||||
|
<li key={`${item}`} className={clsx('dropdown-item', {active: index === highlightedIndex})} {...getItemProps({item, index})}>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const shouldRenderSuggestions = (value: string) => {
|
export default EmailSuggestion;
|
||||||
const isSelecting = [...domainNames].some((name) =>
|
|
||||||
value.endsWith(`@${name}`),
|
|
||||||
)
|
|
||||||
|
|
||||||
return isSelecting || (value.length > 0 && !value.includes('@'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSuggestionValue = (value: string) => value
|
|
||||||
|
|
||||||
const renderSuggestion = (suggestion: string) => suggestion
|
|
||||||
|
|
||||||
const handleChange = (_: React.FormEvent, event: Autosuggest.ChangeEvent) => {
|
|
||||||
props.onChange(event.newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderInputComponent = (
|
|
||||||
props: Omit<Autosuggest.InputProps<string>, 'onChange'>,
|
|
||||||
) => (
|
|
||||||
<div className="input-group">
|
|
||||||
<input className="form-control" {...props} />
|
|
||||||
<div className="input-group-append">
|
|
||||||
<div className="input-group-text">
|
|
||||||
<i className="fas fa-envelope"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div css={styles}>
|
|
||||||
<Autosuggest
|
|
||||||
suggestions={suggestions}
|
|
||||||
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
|
|
||||||
onSuggestionsClearRequested={handleSuggestionsClearRequested}
|
|
||||||
getSuggestionValue={getSuggestionValue}
|
|
||||||
renderSuggestion={renderSuggestion}
|
|
||||||
shouldRenderSuggestions={shouldRenderSuggestions}
|
|
||||||
inputProps={Object.assign({}, props, { onChange: handleChange })}
|
|
||||||
renderInputComponent={renderInputComponent}
|
|
||||||
theme={{
|
|
||||||
container: 'mb-3',
|
|
||||||
suggestion: 'dropdown-item',
|
|
||||||
suggestionsContainer: 'dropdown',
|
|
||||||
suggestionsList: `dropdown-menu ${suggestions.length ? 'show' : ''}`,
|
|
||||||
suggestionHighlighted: 'active',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EmailSuggestion
|
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,52 @@
|
||||||
/** @jsxImportSource @emotion/react */
|
import {t} from '@/scripts/i18n';
|
||||||
import { useRef } from 'react'
|
import {css} from '@emotion/react';
|
||||||
import { css } from '@emotion/react'
|
import {useRef} from 'react';
|
||||||
import { t } from '@/scripts/i18n'
|
|
||||||
|
|
||||||
const hideRawBrowseButton = css`
|
const hideRawBrowseButton = css`
|
||||||
::after {
|
::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
file: File | null
|
file: File | undefined;
|
||||||
accept?: string
|
accept?: string;
|
||||||
onChange(event: React.ChangeEvent<HTMLInputElement>): void
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const FileInput: React.FC<Props> = (props) => {
|
const FileInput: React.FC<Props> = props => {
|
||||||
const ref = useRef<HTMLInputElement>(null)
|
const reference = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
ref.current!.click()
|
reference.current!.click();
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-group">
|
<div className='form-group'>
|
||||||
<label htmlFor="select-file">{t('skinlib.upload.select-file')}</label>
|
<label htmlFor='select-file'>{t('skinlib.upload.select-file')}</label>
|
||||||
<div className="input-group">
|
<div className='input-group'>
|
||||||
<div className="custom-file">
|
<div className='custom-file'>
|
||||||
<input
|
<input
|
||||||
type="file"
|
ref={reference}
|
||||||
className="custom-file-input"
|
type='file'
|
||||||
id="select-file"
|
className='custom-file-input'
|
||||||
accept={props.accept}
|
id='select-file'
|
||||||
title={t('skinlib.upload.select-file')}
|
accept={props.accept}
|
||||||
ref={ref}
|
title={t('skinlib.upload.select-file')}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
/>
|
/>
|
||||||
<label className="custom-file-label" css={hideRawBrowseButton}>
|
<label className='custom-file-label' css={hideRawBrowseButton}>
|
||||||
{props.file?.name}
|
{props.file?.name}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="input-group-append">
|
<div className='input-group-append'>
|
||||||
<button className="btn btn-default" onClick={handleClick}>
|
<button className='btn btn-default' onClick={handleClick}>
|
||||||
{t('skinlib.upload.select-file')}
|
{t('skinlib.upload.select-file')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default FileInput
|
export default FileInput;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import React from 'react'
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<div className='container text-center' title='Loading...'>
|
||||||
|
<i className='fas fa-sync fa-spin'/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Loading = () => (
|
export default Loading;
|
||||||
<div className="container text-center" title="Loading...">
|
|
||||||
<i className="fas fa-sync fa-spin"></i>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default Loading
|
|
||||||
|
|
|
||||||
|
|
@ -1,165 +1,193 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react'
|
import {Modal as BootstrapModal} from 'bootstrap';
|
||||||
import $ from 'jquery'
|
import clsx from 'clsx';
|
||||||
import 'bootstrap'
|
import {useEffect, useRef, useState} from 'react';
|
||||||
import { t } from '../scripts/i18n'
|
import {t} from '../scripts/i18n';
|
||||||
import ModalHeader from './ModalHeader'
|
import ModalBody, {type Props as BodyProps} from './ModalBody';
|
||||||
import ModalBody from './ModalBody'
|
import ModalFooter, {type Props as FooterProps} from './ModalFooter';
|
||||||
import ModalFooter from './ModalFooter'
|
import ModalHeader, {type Props as HeaderProps} from './ModalHeader';
|
||||||
import type { Props as HeaderProps } from './ModalHeader'
|
|
||||||
import type { Props as BodyProps } from './ModalBody'
|
|
||||||
import type { Props as FooterProps } from './ModalFooter'
|
|
||||||
|
|
||||||
type BasicOptions = {
|
type BasicOptions = {
|
||||||
mode?: 'alert' | 'confirm' | 'prompt'
|
readonly mode?: 'alert' | 'confirm' | 'prompt';
|
||||||
show?: boolean
|
readonly show?: boolean;
|
||||||
input?: string
|
readonly input?: string;
|
||||||
validator?(value: any): string | boolean | undefined
|
validator?: (value: any) => string | boolean | undefined;
|
||||||
type?: string
|
readonly type?: string;
|
||||||
showHeader?: boolean
|
readonly showHeader?: boolean;
|
||||||
center?: boolean
|
readonly center?: boolean;
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type ModalOptions = BasicOptions & HeaderProps & BodyProps & FooterProps
|
export type ModalOptions = BasicOptions & HeaderProps & BodyProps & FooterProps;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id?: string
|
readonly id?: string;
|
||||||
children?: React.ReactNode
|
readonly children?: React.ReactNode;
|
||||||
footer?: React.ReactNode
|
readonly footer?: React.ReactNode;
|
||||||
onConfirm?(payload: { value: string }): void
|
onConfirm?: (payload: {value: string}) => void;
|
||||||
onDismiss?(): void
|
onDismiss?: () => void;
|
||||||
onClose?(): void
|
onClose?: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type ModalResult = {
|
export type ModalResult = {
|
||||||
value: string
|
value: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const Modal: React.FC<ModalOptions & Props> = (props) => {
|
const Modal: React.FC<ModalOptions & Props> = props => {
|
||||||
const {
|
const {
|
||||||
mode = 'confirm',
|
mode = 'confirm',
|
||||||
title = t('general.tip'),
|
title = t('general.tip'),
|
||||||
text = '',
|
text = '',
|
||||||
input = '',
|
input = '',
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
inputType = 'text',
|
inputType = 'text',
|
||||||
inputMode,
|
inputMode,
|
||||||
type = 'default',
|
type = 'default',
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
center = false,
|
center = false,
|
||||||
okButtonText = t('general.confirm'),
|
okButtonText = t('general.confirm'),
|
||||||
okButtonType = 'primary',
|
okButtonType = 'primary',
|
||||||
cancelButtonText = t('general.cancel'),
|
cancelButtonText = t('general.cancel'),
|
||||||
cancelButtonType = 'secondary',
|
cancelButtonType = 'secondary',
|
||||||
flexFooter = false,
|
flexFooter = false,
|
||||||
} = props
|
footer,
|
||||||
|
show,
|
||||||
|
onClose,
|
||||||
|
onDismiss,
|
||||||
|
id,
|
||||||
|
validator,
|
||||||
|
onConfirm,
|
||||||
|
children,
|
||||||
|
choices,
|
||||||
|
dangerousHTML: html,
|
||||||
|
} = props;
|
||||||
|
|
||||||
const [value, setValue] = useState(input)
|
const [value, setValue] = useState(input);
|
||||||
const [valid, setValid] = useState(true)
|
const [valid, setValid] = useState(true);
|
||||||
const [validatorMessage, setValidatorMessage] = useState('')
|
const [validatorMessage, setValidatorMessage] = useState('');
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const reference = useRef<HTMLDivElement>(null);
|
||||||
|
const [modal, setModal] = useState<BootstrapModal>();
|
||||||
|
|
||||||
const { show } = props
|
useEffect(() => {
|
||||||
|
if (!reference.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const _modal = new BootstrapModal(reference.current);
|
||||||
if (!show) {
|
setModal(_modal);
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const onHidden = () => props.onClose?.()
|
return () => {
|
||||||
|
_modal.dispose();
|
||||||
|
};
|
||||||
|
}, [reference]);
|
||||||
|
|
||||||
const el = $(ref.current!)
|
useEffect(() => {
|
||||||
el.on('hidden.bs.modal', onHidden)
|
if (!show) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
const onHidden = () => {
|
||||||
el.off('hidden.bs.modal', onHidden)
|
onClose?.();
|
||||||
}
|
};
|
||||||
}, [show, props.onClose])
|
|
||||||
|
|
||||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const element = reference.current;
|
||||||
setValue(event.target.value)
|
if (!element) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const confirm = () => {
|
element.addEventListener('hidden.bs.modal', onHidden);
|
||||||
const { validator } = props
|
|
||||||
if (typeof validator === 'function') {
|
|
||||||
const result = validator(value)
|
|
||||||
if (typeof result === 'string') {
|
|
||||||
setValidatorMessage(result)
|
|
||||||
setValid(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
props.onConfirm?.({ value })
|
return () => {
|
||||||
$(ref.current!).modal('hide')
|
element.removeEventListener('hidden.bs.modal', onHidden);
|
||||||
|
};
|
||||||
|
}, [reference, show, onClose]);
|
||||||
|
|
||||||
// The "hidden.bs.modal" event can't be trigged automatically when testing.
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
/* istanbul ignore next */
|
setValue(event.target.value);
|
||||||
if (process.env.NODE_ENV === 'test') {
|
};
|
||||||
$(ref.current!).trigger('hidden.bs.modal')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dismiss = () => {
|
const confirm = () => {
|
||||||
props.onDismiss?.()
|
if (typeof validator === 'function') {
|
||||||
$(ref.current!).modal('hide')
|
const result = validator(value);
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
setValidatorMessage(result);
|
||||||
|
setValid(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* istanbul ignore next */
|
onConfirm?.({value});
|
||||||
if (process.env.NODE_ENV === 'test') {
|
modal?.hide();
|
||||||
$(ref.current!).trigger('hidden.bs.modal')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
// The "hidden.bs.modal" event can't be trigged automatically when testing.
|
||||||
if (show) {
|
|
||||||
setTimeout(() => $(ref.current!).modal('show'), 50)
|
|
||||||
}
|
|
||||||
}, [show])
|
|
||||||
|
|
||||||
if (!show) {
|
if (import.meta.env.NODE_ENV === 'test') {
|
||||||
return null
|
$(reference.current!).trigger('hidden.bs.modal');
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const dismiss = () => {
|
||||||
<div id={props.id} className="modal fade" role="dialog" ref={ref}>
|
onDismiss?.();
|
||||||
<div
|
modal?.hide();
|
||||||
className={`modal-dialog ${center ? 'modal-dialog-centered' : ''}`}
|
|
||||||
role="document"
|
|
||||||
>
|
|
||||||
<div className={`modal-content bg-${type}`}>
|
|
||||||
<ModalHeader show={showHeader} title={title} onDismiss={dismiss} />
|
|
||||||
<ModalBody
|
|
||||||
text={text}
|
|
||||||
dangerousHTML={props.dangerousHTML}
|
|
||||||
showInput={mode === 'prompt'}
|
|
||||||
value={value}
|
|
||||||
choices={props.choices}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
inputType={inputType}
|
|
||||||
inputMode={inputMode}
|
|
||||||
placeholder={placeholder}
|
|
||||||
invalid={!valid}
|
|
||||||
validatorMessage={validatorMessage}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter
|
|
||||||
showCancelButton={mode !== 'alert'}
|
|
||||||
flexFooter={flexFooter}
|
|
||||||
okButtonType={okButtonType}
|
|
||||||
okButtonText={okButtonText}
|
|
||||||
cancelButtonType={cancelButtonType}
|
|
||||||
cancelButtonText={cancelButtonText}
|
|
||||||
onConfirm={confirm}
|
|
||||||
onDismiss={dismiss}
|
|
||||||
>
|
|
||||||
{props.footer}
|
|
||||||
</ModalFooter>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Modal
|
if (import.meta.env.NODE_ENV === 'test') {
|
||||||
|
$(reference.current!).trigger('hidden.bs.modal');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show && modal) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
modal.show();
|
||||||
|
}, 50);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [show, modal]);
|
||||||
|
|
||||||
|
if (!show) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={reference} id={id} className='modal fade' role='dialog'>
|
||||||
|
<div
|
||||||
|
className={clsx('modal-dialog', center && 'modal-dialog-centered')}
|
||||||
|
role='document'
|
||||||
|
>
|
||||||
|
<div className={`modal-content bg-${type}`}>
|
||||||
|
<ModalHeader show={showHeader} title={title} onDismiss={dismiss}/>
|
||||||
|
<ModalBody
|
||||||
|
text={text}
|
||||||
|
dangerousHTML={html}
|
||||||
|
showInput={mode === 'prompt'}
|
||||||
|
value={value}
|
||||||
|
choices={choices}
|
||||||
|
inputType={inputType}
|
||||||
|
inputMode={inputMode}
|
||||||
|
placeholder={placeholder}
|
||||||
|
invalid={!valid}
|
||||||
|
validatorMessage={validatorMessage}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter
|
||||||
|
showCancelButton={mode !== 'alert'}
|
||||||
|
flexFooter={flexFooter}
|
||||||
|
okButtonType={okButtonType}
|
||||||
|
okButtonText={okButtonText}
|
||||||
|
cancelButtonType={cancelButtonType}
|
||||||
|
cancelButtonText={cancelButtonText}
|
||||||
|
onConfirm={confirm}
|
||||||
|
onDismiss={dismiss}
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,25 @@
|
||||||
import React from 'react'
|
|
||||||
import ModalContent from './ModalContent'
|
|
||||||
import ModalInput from './ModalInput'
|
|
||||||
import type { Props as ContentProps } from './ModalContent'
|
|
||||||
import type {
|
|
||||||
Props as InputProps,
|
|
||||||
InternalProps as InputInteralProps,
|
|
||||||
} from './ModalInput'
|
|
||||||
|
|
||||||
interface InternalProps {
|
import ModalContent, {type Props as ContentProps} from './ModalContent';
|
||||||
showInput: boolean
|
import ModalInput, {
|
||||||
}
|
type
|
||||||
|
InternalProps as InputInteralProps,
|
||||||
|
type
|
||||||
|
Props as InputProps,
|
||||||
|
} from './ModalInput';
|
||||||
|
|
||||||
export type Props = ContentProps & InputProps
|
type InternalProps = {
|
||||||
|
readonly showInput: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const ModalBody: React.FC<InternalProps & InputInteralProps & Props> = (
|
export type Props = ContentProps & InputProps;
|
||||||
props,
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<div className="modal-body">
|
|
||||||
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
|
|
||||||
{props.children}
|
|
||||||
</ModalContent>
|
|
||||||
{props.showInput && <ModalInput {...props} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ModalBody
|
const ModalBody: React.FC<InternalProps & InputInteralProps & Props> = props => (
|
||||||
|
<div className='modal-body'>
|
||||||
|
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
|
||||||
|
{props.children}
|
||||||
|
</ModalContent>
|
||||||
|
{props.showInput && <ModalInput {...props}/>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ModalBody;
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,29 @@
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
export interface Props {
|
export type Props = {
|
||||||
text?: string
|
readonly text?: string;
|
||||||
dangerousHTML?: string
|
readonly dangerousHTML?: string;
|
||||||
}
|
readonly children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
const ModalContent: React.FC<Props> = (props) => {
|
const ModalContent: React.FC<Props> = props => {
|
||||||
if (props.children) {
|
if (props.children) {
|
||||||
return <>{props.children}</>
|
return <>{props.children}</>;
|
||||||
} else if (props.text) {
|
}
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{props.text.split(/\r?\n/).map((line, i) => (
|
|
||||||
<p key={i}>{line}</p>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
} else if (props.dangerousHTML) {
|
|
||||||
return <div dangerouslySetInnerHTML={{ __html: props.dangerousHTML }} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <></>
|
if (props.text) {
|
||||||
}
|
return (
|
||||||
|
<>
|
||||||
|
{props.text.split(/\r?\n/).map((line, i) =>
|
||||||
|
<p key={i}>{line}</p>)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default ModalContent
|
if (props.dangerousHTML) {
|
||||||
|
return <div dangerouslySetInnerHTML={{__html: props.dangerousHTML}}/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalContent;
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,50 @@
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
export interface Props {
|
export type Props = {
|
||||||
flexFooter?: boolean
|
readonly flexFooter?: boolean;
|
||||||
okButtonText?: string
|
readonly okButtonText?: string;
|
||||||
okButtonType?: string
|
readonly okButtonType?: string;
|
||||||
cancelButtonText?: string
|
readonly cancelButtonText?: string;
|
||||||
cancelButtonType?: string
|
readonly cancelButtonType?: string;
|
||||||
}
|
readonly children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
interface InternalProps {
|
type InternalProps = {
|
||||||
showCancelButton: boolean
|
readonly showCancelButton: boolean;
|
||||||
onConfirm?(): void
|
onConfirm?: () => void;
|
||||||
onDismiss?(): void
|
onDismiss?: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const ModalFooter: React.FC<InternalProps & Props> = (props) => {
|
const ModalFooter: React.FC<InternalProps & Props> = props => {
|
||||||
const classes = ['modal-footer']
|
const classes = ['modal-footer'];
|
||||||
if (props.flexFooter) {
|
if (props.flexFooter) {
|
||||||
classes.push('d-flex', 'justify-content-between')
|
classes.push('d-flex', 'justify-content-between');
|
||||||
}
|
}
|
||||||
const footerClass = classes.join(' ')
|
|
||||||
|
|
||||||
return props.children ? (
|
const footerClass = classes.join(' ');
|
||||||
<div className={footerClass}>{props.children}</div>
|
|
||||||
) : (
|
|
||||||
<div className={footerClass}>
|
|
||||||
{props.showCancelButton && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn btn-${props.cancelButtonType}`}
|
|
||||||
data-dismiss="modal"
|
|
||||||
onClick={props.onDismiss}
|
|
||||||
>
|
|
||||||
{props.cancelButtonText}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn btn-${props.okButtonType}`}
|
|
||||||
onClick={props.onConfirm}
|
|
||||||
>
|
|
||||||
{props.okButtonText}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ModalFooter
|
return props.children
|
||||||
|
? <div className={footerClass}>{props.children}</div>
|
||||||
|
: (
|
||||||
|
<div className={footerClass}>
|
||||||
|
{props.showCancelButton && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className={`btn btn-${props.cancelButtonType}`}
|
||||||
|
data-dismiss='modal'
|
||||||
|
onClick={props.onDismiss}
|
||||||
|
>
|
||||||
|
{props.cancelButtonText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className={`btn btn-${props.okButtonType}`}
|
||||||
|
onClick={props.onConfirm}
|
||||||
|
>
|
||||||
|
{props.okButtonText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalFooter;
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,27 @@
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
export interface Props {
|
export type Props = {
|
||||||
title?: string
|
readonly title?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface InternalProps {
|
type InternalProps = {
|
||||||
onDismiss?(): void
|
onDismiss?: () => void;
|
||||||
show?: boolean
|
readonly show?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
const ModalHeader: React.FC<Props & InternalProps> = (props) =>
|
const ModalHeader: React.FC<Props & InternalProps> = ({show, title, onDismiss}) =>
|
||||||
props.show ? (
|
show
|
||||||
<div className="modal-header">
|
? (
|
||||||
<h5 className="modal-title">{props.title}</h5>
|
<div className='modal-header'>
|
||||||
<button
|
<h5 className='modal-title'>{title}</h5>
|
||||||
type="button"
|
<button
|
||||||
className="close"
|
type='button'
|
||||||
data-dismiss="modal"
|
className='btn-close'
|
||||||
aria-label="Close"
|
data-bs-dismiss='modal'
|
||||||
onClick={props.onDismiss}
|
aria-label='Close'
|
||||||
>
|
onClick={onDismiss}
|
||||||
<span aria-hidden>×</span>
|
/>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
)
|
||||||
) : null
|
: null;
|
||||||
|
|
||||||
export default ModalHeader
|
export default ModalHeader;
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,59 @@
|
||||||
import React, { HTMLAttributes } from 'react'
|
|
||||||
|
|
||||||
export interface Props {
|
export type Props = {
|
||||||
inputType?: string
|
readonly inputType?: string;
|
||||||
inputMode?: HTMLAttributes<HTMLInputElement>['inputMode']
|
readonly inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
|
||||||
choices?: { text: string; value: string }[]
|
readonly choices?: Array<{text: string; value: string}>;
|
||||||
placeholder?: string
|
readonly placeholder?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface InternalProps {
|
export type InternalProps = {
|
||||||
value?: string
|
readonly value?: string;
|
||||||
invalid?: boolean
|
readonly invalid?: boolean;
|
||||||
validatorMessage?: string
|
readonly validatorMessage?: string;
|
||||||
onChange?: React.ChangeEventHandler<HTMLInputElement>
|
readonly onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||||
}
|
};
|
||||||
|
|
||||||
const ModalInput: React.FC<InternalProps & Props> = (props) => (
|
const ModalInput: React.FC<InternalProps & Props> = props => (
|
||||||
<>
|
<>
|
||||||
{props.inputType === 'radios' && props.choices ? (
|
{props.inputType === 'radios' && props.choices
|
||||||
<>
|
? (
|
||||||
{props.choices.map((choice) => (
|
<>
|
||||||
<div key={choice.value}>
|
{props.choices.map(choice => (
|
||||||
<input
|
<div key={choice.value}>
|
||||||
type="radio"
|
<input
|
||||||
name="modal-radios"
|
type='radio'
|
||||||
id={`modal-radio-${choice.value}`}
|
name='modal-radios'
|
||||||
value={choice.value}
|
id={`modal-radio-${choice.value}`}
|
||||||
checked={choice.value === props.value}
|
value={choice.value}
|
||||||
onChange={props.onChange}
|
checked={choice.value === props.value}
|
||||||
/>
|
onChange={props.onChange}
|
||||||
<label htmlFor={`modal-radio-${choice.value}`} className="ml-1">
|
/>
|
||||||
{choice.text}
|
<label htmlFor={`modal-radio-${choice.value}`} className='ml-1'>
|
||||||
</label>
|
{choice.text}
|
||||||
</div>
|
</label>
|
||||||
))}
|
</div>
|
||||||
</>
|
))}
|
||||||
) : (
|
</>
|
||||||
<div className="form-group">
|
)
|
||||||
<input
|
: (
|
||||||
value={props.value}
|
<div className='form-group'>
|
||||||
onChange={props.onChange}
|
<input
|
||||||
type={props.inputType}
|
value={props.value}
|
||||||
inputMode={props.inputMode}
|
type={props.inputType}
|
||||||
className="form-control"
|
inputMode={props.inputMode}
|
||||||
placeholder={props.placeholder}
|
className='form-control'
|
||||||
></input>
|
placeholder={props.placeholder}
|
||||||
</div>
|
onChange={props.onChange}
|
||||||
)}
|
/>
|
||||||
{props.invalid && (
|
</div>
|
||||||
<div className="alert alert-danger">
|
)}
|
||||||
<i className="icon far fa-times-circle"></i>
|
{props.invalid && (
|
||||||
<span className="ml-1">{props.validatorMessage}</span>
|
<div className='alert alert-danger'>
|
||||||
</div>
|
<i className='icon far fa-times-circle'/>
|
||||||
)}
|
<span className='ml-1'>{props.validatorMessage}</span>
|
||||||
</>
|
</div>
|
||||||
)
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
export default ModalInput
|
export default ModalInput;
|
||||||
|
|
|
||||||
|
|
@ -1,124 +1,122 @@
|
||||||
import React from 'react'
|
|
||||||
import { t } from '@/scripts/i18n'
|
|
||||||
import PaginationItem from './PaginationItem'
|
|
||||||
|
|
||||||
interface Props {
|
import {t} from '@/scripts/i18n';
|
||||||
page: number
|
import PaginationItem from './PaginationItem';
|
||||||
totalPages: number
|
|
||||||
onChange(page: number): void | Promise<void>
|
type Props = {
|
||||||
}
|
readonly page: number;
|
||||||
|
readonly totalPages: number;
|
||||||
|
onChange: (page: number) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
const labels = {
|
const labels = {
|
||||||
prev: '‹',
|
prev: '‹',
|
||||||
next: '›',
|
next: '›',
|
||||||
}
|
};
|
||||||
|
|
||||||
const Pagination: React.FC<Props> = (props) => {
|
const Pagination: React.FC<Props> = props => {
|
||||||
const { page, totalPages, onChange } = props
|
const {page, totalPages, onChange} = props;
|
||||||
|
|
||||||
if (totalPages < 1) {
|
if (totalPages < 1) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="pagination">
|
<ul className='pagination'>
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
title={t('vendor.datatable.prev')}
|
title={t('vendor.datatable.prev')}
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
onClick={() => onChange(page - 1)}
|
onClick={async () => onChange(page - 1)}
|
||||||
>
|
>
|
||||||
{labels.prev}
|
{labels.prev}
|
||||||
<span className="d-inline d-sm-none ml-1">
|
<span className='d-inline d-sm-none ml-1'>
|
||||||
{t('vendor.datatable.prev')}
|
{t('vendor.datatable.prev')}
|
||||||
</span>
|
</span>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
{totalPages < 8 ? (
|
{totalPages < 8
|
||||||
Array.from({ length: totalPages }).map((_, i) => (
|
? Array.from({length: totalPages}).map((_, i) => (
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
key={i}
|
key={i}
|
||||||
className="d-none d-sm-block"
|
className='d-none d-sm-block'
|
||||||
active={page === i + 1}
|
active={page === i + 1}
|
||||||
onClick={() => onChange(i + 1)}
|
onClick={async () => onChange(i + 1)}
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))
|
))
|
||||||
) : (
|
: (
|
||||||
<>
|
<>
|
||||||
{page < 4 ? (
|
{page < 4
|
||||||
[1, 2, 3, 4].map((n) => (
|
? [1, 2, 3, 4].map(n => (
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
key={n}
|
key={n}
|
||||||
className="d-none d-sm-block"
|
className='d-none d-sm-block'
|
||||||
active={page === n}
|
active={page === n}
|
||||||
onClick={() => onChange(n)}
|
onClick={async () => onChange(n)}
|
||||||
>
|
>
|
||||||
{n}
|
{n}
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))
|
))
|
||||||
) : (
|
: (
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
className="d-none d-sm-block"
|
className='d-none d-sm-block'
|
||||||
onClick={() => onChange(1)}
|
onClick={async () => onChange(1)}
|
||||||
>
|
>
|
||||||
1
|
1
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
)}
|
)}
|
||||||
<PaginationItem className="d-none d-sm-block" disabled>
|
<PaginationItem disabled className='d-none d-sm-block'>
|
||||||
...
|
...
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
{page > 3 && page < totalPages - 2 && (
|
{page > 3 && page < totalPages - 2 && (
|
||||||
<>
|
<>
|
||||||
{[page - 1, page, page + 1].map((n) => (
|
{[page - 1, page, page + 1].map(n => (
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
key={n}
|
key={n}
|
||||||
className="d-none d-sm-block"
|
className='d-none d-sm-block'
|
||||||
active={page === n}
|
active={page === n}
|
||||||
onClick={() => onChange(n)}
|
onClick={async () => onChange(n)}
|
||||||
>
|
>
|
||||||
{n}
|
{n}
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))}
|
))}
|
||||||
<PaginationItem className="d-none d-sm-block" disabled>
|
<PaginationItem disabled className='d-none d-sm-block'>
|
||||||
...
|
...
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{totalPages - page < 3 ? (
|
{totalPages - page < 3
|
||||||
[totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map(
|
? [totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map(n => (
|
||||||
(n) => (
|
<PaginationItem
|
||||||
<PaginationItem
|
key={n}
|
||||||
key={n}
|
className='d-none d-sm-block'
|
||||||
className="d-none d-sm-block"
|
active={page === n}
|
||||||
active={page === n}
|
onClick={async () => onChange(n)}
|
||||||
onClick={() => onChange(n)}
|
>
|
||||||
>
|
{n}
|
||||||
{n}
|
</PaginationItem>
|
||||||
</PaginationItem>
|
))
|
||||||
),
|
: (
|
||||||
)
|
<PaginationItem
|
||||||
) : (
|
className='d-none d-sm-block'
|
||||||
<PaginationItem
|
onClick={async () => onChange(totalPages)}
|
||||||
className="d-none d-sm-block"
|
>
|
||||||
onClick={() => onChange(totalPages)}
|
{totalPages}
|
||||||
>
|
</PaginationItem>
|
||||||
{totalPages}
|
)}
|
||||||
</PaginationItem>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
<PaginationItem
|
||||||
)}
|
title={t('vendor.datatable.next')}
|
||||||
<PaginationItem
|
disabled={page === totalPages}
|
||||||
title={t('vendor.datatable.next')}
|
onClick={async () => onChange(page + 1)}
|
||||||
disabled={page === totalPages}
|
>
|
||||||
onClick={() => onChange(page + 1)}
|
<span className='d-inline d-sm-none mr-1'>
|
||||||
>
|
{t('vendor.datatable.next')}
|
||||||
<span className="d-inline d-sm-none mr-1">
|
</span>
|
||||||
{t('vendor.datatable.next')}
|
{labels.next}
|
||||||
</span>
|
</PaginationItem>
|
||||||
{labels.next}
|
</ul>
|
||||||
</PaginationItem>
|
);
|
||||||
</ul>
|
};
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Pagination
|
export default Pagination;
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,41 @@
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
disabled?: boolean
|
readonly disabled?: boolean;
|
||||||
active?: boolean
|
readonly active?: boolean;
|
||||||
title?: string
|
readonly title?: string;
|
||||||
className?: string
|
readonly className?: string;
|
||||||
onClick?(): void
|
onClick?: () => void;
|
||||||
}
|
readonly children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
const PaginationItem: React.FC<Props> = (props) => {
|
const PaginationItem: React.FC<Props> = props => {
|
||||||
const classes = ['page-item']
|
const classes = ['page-item'];
|
||||||
if (props.active) {
|
if (props.active) {
|
||||||
classes.push('active')
|
classes.push('active');
|
||||||
}
|
}
|
||||||
if (props.disabled) {
|
|
||||||
classes.push('disabled')
|
|
||||||
}
|
|
||||||
if (props.className) {
|
|
||||||
classes.push(props.className)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClick = (event: React.MouseEvent) => {
|
if (props.disabled) {
|
||||||
event.preventDefault()
|
classes.push('disabled');
|
||||||
if (!props.disabled && props.onClick) {
|
}
|
||||||
props.onClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
if (props.className) {
|
||||||
<li className={classes.join(' ')} title={props.title} onClick={handleClick}>
|
classes.push(props.className);
|
||||||
<a href="#" className="page-link" aria-disabled={props.disabled}>
|
}
|
||||||
{props.children}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PaginationItem
|
const handleClick = (event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!props.disabled && props.onClick) {
|
||||||
|
props.onClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={classes.join(' ')} title={props.title} onClick={handleClick}>
|
||||||
|
<a href='#' className='page-link' aria-disabled={props.disabled}>
|
||||||
|
{props.children}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaginationItem;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
/** @jsxImportSource @emotion/react */
|
import {css} from '@emotion/react';
|
||||||
import React, { useState, useEffect } from 'react'
|
import {useEffect, useState} from 'react';
|
||||||
import { css } from '@emotion/react'
|
|
||||||
|
|
||||||
export type ToastType = 'success' | 'info' | 'warning' | 'error'
|
export type ToastType = 'success' | 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
type: ToastType
|
readonly type: ToastType;
|
||||||
distance: number
|
readonly distance: number;
|
||||||
onClose(): void | Promise<void>
|
onClose: () => void | Promise<void>;
|
||||||
}
|
readonly children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
const icons = new Map<ToastType, string>([
|
const icons = new Map<ToastType, string>([
|
||||||
['success', 'check'],
|
['success', 'check'],
|
||||||
['info', 'info'],
|
['info', 'info'],
|
||||||
['warning', 'exclamation-triangle'],
|
['warning', 'exclamation-triangle'],
|
||||||
['error', 'times-circle'],
|
['error', 'times-circle'],
|
||||||
])
|
]);
|
||||||
|
|
||||||
const wrapper = css`
|
const wrapper = css`
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -24,52 +24,54 @@ const wrapper = css`
|
||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
transition-property: top;
|
transition-property: top;
|
||||||
transition-duration: 0.3s;
|
transition-duration: 0.3s;
|
||||||
`
|
`;
|
||||||
const shadow = css`
|
const shadow = css`
|
||||||
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
|
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
|
||||||
`
|
`;
|
||||||
|
|
||||||
const Toast: React.FC<Props> = (props) => {
|
const Toast: React.FC<Props> = props => {
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setShow(true), 100)
|
const timer = setTimeout(() => {
|
||||||
|
setShow(true);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer);
|
||||||
}
|
};
|
||||||
}, [props.onClose])
|
}, [props.onClose]);
|
||||||
|
|
||||||
const type = props.type === 'error' ? 'danger' : props.type
|
const type = props.type === 'error' ? 'danger' : props.type;
|
||||||
|
|
||||||
const classes = [
|
const classes = [
|
||||||
`alert alert-${type}`,
|
`alert alert-${type}`,
|
||||||
'd-flex justify-content-between',
|
'd-flex justify-content-between',
|
||||||
'fade',
|
'fade',
|
||||||
]
|
];
|
||||||
if (show) {
|
if (show) {
|
||||||
classes.push('show')
|
classes.push('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = type === 'success' || type === 'info' ? 'status' : 'alert'
|
const role = type === 'success' || type === 'info' ? 'status' : 'alert';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div css={wrapper} style={{ top: `${props.distance}px` }}>
|
<div css={wrapper} style={{top: `${props.distance}px`}}>
|
||||||
<div className={classes.join(' ')} css={shadow} role={role}>
|
<div className={classes.join(' ')} css={shadow} role={role}>
|
||||||
<span className="mr-1 d-flex align-items-center">
|
<span className='mr-1 d-flex align-items-center'>
|
||||||
<i className={`icon fas fa-${icons.get(props.type)}`}></i>
|
<i className={`icon fas fa-${icons.get(props.type)}`}/>
|
||||||
</span>
|
</span>
|
||||||
<span>{props.children}</span>
|
<span>{props.children}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type='button'
|
||||||
className="mr-2 ml-1 close"
|
className='mr-2 ml-1 close'
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Toast
|
export default Toast;
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,38 @@
|
||||||
/** @jsxImportSource @emotion/react */
|
import {t} from '@/scripts/i18n';
|
||||||
import React, { useState, useEffect, useRef } from 'react'
|
import * as breakpoints from '@/styles/breakpoints';
|
||||||
import { useMeasure } from 'react-use'
|
import * as cssUtils from '@/styles/utils';
|
||||||
import { css } from '@emotion/react'
|
import {css} from '@emotion/react';
|
||||||
import styled from '@emotion/styled'
|
import styled from '@emotion/styled';
|
||||||
import * as skinview3d from 'skinview3d'
|
import {useEffect, useRef, useState} from 'react';
|
||||||
import { t } from '@/scripts/i18n'
|
import {useMeasure} from 'react-use';
|
||||||
import * as cssUtils from '@/styles/utils'
|
import * as skinview3d from 'skinview3d';
|
||||||
import * as breakpoints from '@/styles/breakpoints'
|
import bg1 from '../../../misc/backgrounds/1.webp';
|
||||||
import SkinSteve from '../../../misc/textures/steve.png'
|
import bg2 from '../../../misc/backgrounds/2.webp';
|
||||||
import bg1 from '../../../misc/backgrounds/1.webp'
|
import bg3 from '../../../misc/backgrounds/3.webp';
|
||||||
import bg2 from '../../../misc/backgrounds/2.webp'
|
import bg4 from '../../../misc/backgrounds/4.webp';
|
||||||
import bg3 from '../../../misc/backgrounds/3.webp'
|
import bg5 from '../../../misc/backgrounds/5.webp';
|
||||||
import bg4 from '../../../misc/backgrounds/4.webp'
|
import bg6 from '../../../misc/backgrounds/6.webp';
|
||||||
import bg5 from '../../../misc/backgrounds/5.webp'
|
import bg7 from '../../../misc/backgrounds/7.webp';
|
||||||
import bg6 from '../../../misc/backgrounds/6.webp'
|
import SkinSteve from '../../../misc/textures/steve.png';
|
||||||
import bg7 from '../../../misc/backgrounds/7.webp'
|
|
||||||
|
|
||||||
const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7]
|
const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7];
|
||||||
export const PICTURES_COUNT = backgrounds.length
|
export const PICTURES_COUNT = backgrounds.length;
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
skin?: string
|
readonly skin?: string;
|
||||||
cape?: string
|
readonly cape?: string;
|
||||||
isAlex: boolean
|
readonly children?: React.ReactNode;
|
||||||
showIndicator?: boolean
|
readonly isAlex: boolean;
|
||||||
initPositionZ?: number
|
readonly showIndicator?: boolean;
|
||||||
}
|
readonly initPositionZ?: number;
|
||||||
|
};
|
||||||
|
|
||||||
const animationFactories = [
|
const animationFactories = [
|
||||||
() => new skinview3d.WalkingAnimation(),
|
() => new skinview3d.WalkingAnimation(),
|
||||||
() => new skinview3d.RunningAnimation(),
|
() => new skinview3d.RunningAnimation(),
|
||||||
() => new skinview3d.FlyingAnimation(),
|
() => new skinview3d.FlyingAnimation(),
|
||||||
() => new skinview3d.IdleAnimation(),
|
() => new skinview3d.IdleAnimation(),
|
||||||
]
|
];
|
||||||
|
|
||||||
const ActionButton = styled.i`
|
const ActionButton = styled.i`
|
||||||
display: inline;
|
display: inline;
|
||||||
|
|
@ -41,7 +41,7 @@ const ActionButton = styled.i`
|
||||||
color: #555;
|
color: #555;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
const cssViewer = css`
|
const cssViewer = css`
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|
@ -56,251 +56,255 @@ const cssViewer = css`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
const Viewer: React.FC<Props> = (props) => {
|
const Viewer: React.FC<Props> = props => {
|
||||||
const { initPositionZ = 70 } = props
|
const {initPositionZ = 70} = props;
|
||||||
|
|
||||||
const viewRef: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!)
|
const viewReference: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!);
|
||||||
const containerRef = useRef<HTMLCanvasElement>(null)
|
const containerReference = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
const [paused, setPaused] = useState(false)
|
const [paused, setPaused] = useState(false);
|
||||||
const [animation, setAnimation] = useState(0)
|
const [animation, setAnimation] = useState(0);
|
||||||
const [bgPicture, setBgPicture] = useState(-1)
|
const [bgPicture, setBgPicture] = useState(-1);
|
||||||
|
|
||||||
const indicator = (() => {
|
const indicator = (() => {
|
||||||
const { skin, cape } = props
|
const {skin, cape} = props;
|
||||||
if (skin && cape) {
|
if (skin && cape) {
|
||||||
return `${t('general.skin')} & ${t('general.cape')}`
|
return `${t('general.skin')} & ${t('general.cape')}`;
|
||||||
} else if (skin) {
|
}
|
||||||
return t('general.skin')
|
|
||||||
} else if (cape) {
|
|
||||||
return t('general.cape')
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})()
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (skin) {
|
||||||
const container = containerRef.current!
|
return t('general.skin');
|
||||||
const viewer = new skinview3d.SkinViewer({
|
}
|
||||||
canvas: container,
|
|
||||||
width: container.clientWidth,
|
|
||||||
height: container.clientHeight,
|
|
||||||
skin: props.skin || SkinSteve,
|
|
||||||
cape: props.cape || undefined,
|
|
||||||
model: props.isAlex ? 'slim' : 'default',
|
|
||||||
zoom: initPositionZ / 100,
|
|
||||||
})
|
|
||||||
viewer.autoRotate = true
|
|
||||||
|
|
||||||
if (document.body.classList.contains('dark-mode')) {
|
if (cape) {
|
||||||
viewer.background = '#6c757d'
|
return t('general.cape');
|
||||||
}
|
}
|
||||||
|
|
||||||
viewRef.current = viewer
|
return '';
|
||||||
|
})();
|
||||||
|
|
||||||
return () => {
|
useEffect(() => {
|
||||||
viewer.dispose()
|
const container = containerReference.current!;
|
||||||
}
|
const viewer = new skinview3d.SkinViewer({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
canvas: container,
|
||||||
}, [])
|
width: container.clientWidth,
|
||||||
|
height: container.clientHeight,
|
||||||
|
skin: props.skin || SkinSteve,
|
||||||
|
cape: props.cape || undefined,
|
||||||
|
model: props.isAlex ? 'slim' : 'default',
|
||||||
|
zoom: initPositionZ / 100,
|
||||||
|
});
|
||||||
|
viewer.autoRotate = true;
|
||||||
|
|
||||||
const [containerWrapperRef, containerMeasure] = useMeasure<HTMLDivElement>()
|
if (document.body.classList.contains('dark-mode')) {
|
||||||
useEffect(() => {
|
viewer.background = '#6c757d';
|
||||||
viewRef.current.setSize(containerMeasure.width, containerMeasure.height)
|
}
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
viewReference.current = viewer;
|
||||||
const viewer = viewRef.current
|
|
||||||
viewer.loadSkin(props.skin || SkinSteve, {
|
|
||||||
model: props.isAlex ? 'slim' : 'default',
|
|
||||||
})
|
|
||||||
}, [props.skin, props.isAlex])
|
|
||||||
|
|
||||||
useEffect(() => {
|
return () => {
|
||||||
const viewer = viewRef.current
|
viewer.dispose();
|
||||||
if (props.cape) {
|
};
|
||||||
viewer.loadCape(props.cape)
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
} else {
|
}, []);
|
||||||
viewer.resetCape()
|
|
||||||
}
|
|
||||||
}, [props.cape])
|
|
||||||
|
|
||||||
useEffect(() => {
|
const [containerWrapperReference, containerMeasure] = useMeasure<HTMLDivElement>();
|
||||||
const viewer = viewRef.current
|
useEffect(() => {
|
||||||
const factory = animationFactories[animation]
|
viewReference.current.setSize(containerMeasure.width, containerMeasure.height);
|
||||||
if (factory === undefined) {
|
});
|
||||||
viewer.animation = null
|
|
||||||
} else {
|
|
||||||
const newAnimation = factory()
|
|
||||||
newAnimation.paused = paused // Perseve `paused` state
|
|
||||||
viewer.animation = newAnimation
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [animation])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentAnimation = viewRef.current.animation
|
const viewer = viewReference.current;
|
||||||
if (currentAnimation !== null) {
|
viewer.loadSkin(props.skin || SkinSteve, {
|
||||||
currentAnimation.paused = paused
|
model: props.isAlex ? 'slim' : 'default',
|
||||||
}
|
});
|
||||||
}, [paused])
|
}, [props.skin, props.isAlex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const viewer = viewRef.current
|
const viewer = viewReference.current;
|
||||||
const backgroundUrl = backgrounds[bgPicture]
|
if (props.cape) {
|
||||||
if (backgroundUrl === undefined) {
|
viewer.loadCape(props.cape);
|
||||||
viewer.background = null
|
} else {
|
||||||
} else {
|
viewer.resetCape();
|
||||||
viewer.loadBackground(backgroundUrl)
|
}
|
||||||
}
|
}, [props.cape]);
|
||||||
}, [bgPicture])
|
|
||||||
|
|
||||||
const togglePause = () => {
|
useEffect(() => {
|
||||||
setPaused((paused) => {
|
const viewer = viewReference.current;
|
||||||
if (paused) {
|
const factory = animationFactories[animation];
|
||||||
return false
|
if (factory === undefined) {
|
||||||
} else {
|
viewer.animation = null;
|
||||||
viewRef.current.autoRotate = false
|
} else {
|
||||||
return true
|
const newAnimation = factory();
|
||||||
}
|
newAnimation.paused = paused; // Perseve `paused` state
|
||||||
})
|
viewer.animation = newAnimation;
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [animation]);
|
||||||
|
|
||||||
const toggleAnimation = () => {
|
useEffect(() => {
|
||||||
setAnimation((index) => (index + 1) % animationFactories.length)
|
const currentAnimation = viewReference.current.animation;
|
||||||
setPaused(false)
|
if (currentAnimation !== null) {
|
||||||
}
|
currentAnimation.paused = paused;
|
||||||
|
}
|
||||||
|
}, [paused]);
|
||||||
|
|
||||||
const toggleRotate = () => {
|
useEffect(() => {
|
||||||
const viewer = viewRef.current
|
const viewer = viewReference.current;
|
||||||
viewer.autoRotate = !viewer.autoRotate
|
const backgroundUrl = backgrounds[bgPicture];
|
||||||
}
|
if (backgroundUrl === undefined) {
|
||||||
|
viewer.background = null;
|
||||||
|
} else {
|
||||||
|
viewer.loadBackground(backgroundUrl);
|
||||||
|
}
|
||||||
|
}, [bgPicture]);
|
||||||
|
|
||||||
const toggleBackEquippment = () => {
|
const togglePause = () => {
|
||||||
const player = viewRef.current.playerObject
|
setPaused(paused => {
|
||||||
if (player.backEquipment === 'cape') {
|
if (paused) {
|
||||||
player.backEquipment = 'elytra'
|
return false;
|
||||||
} else {
|
}
|
||||||
player.backEquipment = 'cape'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setWhite = () => {
|
viewReference.current.autoRotate = false;
|
||||||
viewRef.current.background = '#fff'
|
return true;
|
||||||
}
|
});
|
||||||
const setGray = () => {
|
};
|
||||||
viewRef.current.background = '#6c757d'
|
|
||||||
}
|
|
||||||
const setBlack = () => {
|
|
||||||
viewRef.current.background = '#000'
|
|
||||||
}
|
|
||||||
const setPrevPicture = () => {
|
|
||||||
setBgPicture((index) => {
|
|
||||||
if (bgPicture <= 0) {
|
|
||||||
return PICTURES_COUNT - 1
|
|
||||||
} else {
|
|
||||||
return index - 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const setNextPicture = () => {
|
|
||||||
setBgPicture((index) => {
|
|
||||||
if (bgPicture >= PICTURES_COUNT - 1) {
|
|
||||||
return 0
|
|
||||||
} else {
|
|
||||||
return index + 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
const toggleAnimation = () => {
|
||||||
<div className="card">
|
setAnimation(index => (index + 1) % animationFactories.length);
|
||||||
<div className="card-header">
|
setPaused(false);
|
||||||
<div className="d-flex justify-content-between">
|
};
|
||||||
<h3 className="card-title">
|
|
||||||
<span>{t('general.texturePreview')}</span>
|
|
||||||
{props.showIndicator && (
|
|
||||||
<span className="badge bg-olive ml-1">{indicator}</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<div>
|
|
||||||
<ActionButton
|
|
||||||
className={`fas fa-tablet ${props.cape ? '' : 'd-none'}`}
|
|
||||||
data-toggle="tooltip"
|
|
||||||
data-placement="bottom"
|
|
||||||
title={t('general.switchCapeElytra')}
|
|
||||||
onClick={toggleBackEquippment}
|
|
||||||
></ActionButton>
|
|
||||||
<ActionButton
|
|
||||||
className={`fas fa-person-running`}
|
|
||||||
data-toggle="tooltip"
|
|
||||||
data-placement="bottom"
|
|
||||||
title={t('general.switchAnimation')}
|
|
||||||
onClick={toggleAnimation}
|
|
||||||
></ActionButton>
|
|
||||||
<ActionButton
|
|
||||||
className={`fas fa-${paused ? 'play' : 'pause'}`}
|
|
||||||
data-toggle="tooltip"
|
|
||||||
data-placement="bottom"
|
|
||||||
title={
|
|
||||||
paused
|
|
||||||
? t('general.playAnimation')
|
|
||||||
: t('general.pauseAnimation')
|
|
||||||
}
|
|
||||||
onClick={togglePause}
|
|
||||||
></ActionButton>
|
|
||||||
<ActionButton
|
|
||||||
className="fas fa-rotate-right"
|
|
||||||
data-toggle="tooltip"
|
|
||||||
data-placement="bottom"
|
|
||||||
title={t('general.rotation')}
|
|
||||||
onClick={toggleRotate}
|
|
||||||
></ActionButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div ref={containerWrapperRef} css={cssViewer} className="p-0">
|
|
||||||
<canvas ref={containerRef}></canvas>
|
|
||||||
</div>
|
|
||||||
<div className="card-footer">
|
|
||||||
<div className="mt-2 mb-3 d-flex">
|
|
||||||
<div
|
|
||||||
className="btn-color bg-white rounded-pill mr-2 elevation-2"
|
|
||||||
title={t('colors.white')}
|
|
||||||
onClick={setWhite}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="btn-color bg-black rounded-pill mr-2 elevation-2"
|
|
||||||
title={t('colors.black')}
|
|
||||||
onClick={setBlack}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="btn-color bg-gray rounded-pill mr-2 elevation-2"
|
|
||||||
title={t('colors.gray')}
|
|
||||||
onClick={setGray}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="btn-color bg-green rounded-pill mr-2 elevation-2"
|
|
||||||
css={cssUtils.center}
|
|
||||||
title={t('colors.prev')}
|
|
||||||
onClick={setPrevPicture}
|
|
||||||
>
|
|
||||||
<i className="fas fa-arrow-left"></i>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="btn-color bg-green rounded-pill mr-2 elevation-2"
|
|
||||||
css={cssUtils.center}
|
|
||||||
title={t('colors.next')}
|
|
||||||
onClick={setNextPicture}
|
|
||||||
>
|
|
||||||
<i className="fas fa-arrow-right"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Viewer
|
const toggleRotate = () => {
|
||||||
|
const viewer = viewReference.current;
|
||||||
|
viewer.autoRotate = !viewer.autoRotate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBackEquippment = () => {
|
||||||
|
const player = viewReference.current.playerObject;
|
||||||
|
player.backEquipment = player.backEquipment === 'cape' ? 'elytra' : 'cape';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setWhite = () => {
|
||||||
|
viewReference.current.background = '#fff';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setGray = () => {
|
||||||
|
viewReference.current.background = '#6c757d';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBlack = () => {
|
||||||
|
viewReference.current.background = '#000';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPreviousPicture = () => {
|
||||||
|
setBgPicture(index => {
|
||||||
|
if (bgPicture <= 0) {
|
||||||
|
return PICTURES_COUNT - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return index - 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setNextPicture = () => {
|
||||||
|
setBgPicture(index => {
|
||||||
|
if (bgPicture >= PICTURES_COUNT - 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return index + 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='card'>
|
||||||
|
<div className='card-header'>
|
||||||
|
<div className='d-flex justify-content-between'>
|
||||||
|
<h3 className='card-title'>
|
||||||
|
<span>{t('general.texturePreview')}</span>
|
||||||
|
{props.showIndicator
|
||||||
|
&& <span className='badge bg-olive ml-1'>{indicator}</span>}
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<ActionButton
|
||||||
|
className={`fas fa-tablet ${props.cape ? '' : 'd-none'}`}
|
||||||
|
data-toggle='tooltip'
|
||||||
|
data-placement='bottom'
|
||||||
|
title={t('general.switchCapeElytra')}
|
||||||
|
onClick={toggleBackEquippment}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
className='fas fa-person-running'
|
||||||
|
data-toggle='tooltip'
|
||||||
|
data-placement='bottom'
|
||||||
|
title={t('general.switchAnimation')}
|
||||||
|
onClick={toggleAnimation}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
className={`fas fa-${paused ? 'play' : 'pause'}`}
|
||||||
|
data-toggle='tooltip'
|
||||||
|
data-placement='bottom'
|
||||||
|
title={
|
||||||
|
paused
|
||||||
|
? t('general.playAnimation')
|
||||||
|
: t('general.pauseAnimation')
|
||||||
|
}
|
||||||
|
onClick={togglePause}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
className='fas fa-rotate-right'
|
||||||
|
data-toggle='tooltip'
|
||||||
|
data-placement='bottom'
|
||||||
|
title={t('general.rotation')}
|
||||||
|
onClick={toggleRotate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={containerWrapperReference} css={cssViewer} className='p-0'>
|
||||||
|
<canvas ref={containerReference}/>
|
||||||
|
</div>
|
||||||
|
<div className='card-footer'>
|
||||||
|
<div className='mt-2 mb-3 d-flex'>
|
||||||
|
<div
|
||||||
|
className='btn-color bg-white rounded-pill mr-2 elevation-2'
|
||||||
|
title={t('colors.white')}
|
||||||
|
onClick={setWhite}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className='btn-color bg-black rounded-pill mr-2 elevation-2'
|
||||||
|
title={t('colors.black')}
|
||||||
|
onClick={setBlack}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className='btn-color bg-gray rounded-pill mr-2 elevation-2'
|
||||||
|
title={t('colors.gray')}
|
||||||
|
onClick={setGray}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className='btn-color bg-green rounded-pill mr-2 elevation-2'
|
||||||
|
css={cssUtils.center}
|
||||||
|
title={t('colors.prev')}
|
||||||
|
onClick={setPreviousPicture}
|
||||||
|
>
|
||||||
|
<i className='fas fa-arrow-left'/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className='btn-color bg-green rounded-pill mr-2 elevation-2'
|
||||||
|
css={cssUtils.center}
|
||||||
|
title={t('colors.next')}
|
||||||
|
onClick={setNextPicture}
|
||||||
|
>
|
||||||
|
<i className='fas fa-arrow-right'/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Viewer;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import React from 'react'
|
import {t} from '@/scripts/i18n';
|
||||||
import { t } from '@/scripts/i18n'
|
|
||||||
|
|
||||||
const ViewerSkeleton: React.FC = () => (
|
export default function ViewerSkeleton() {
|
||||||
<div className="card">
|
return (
|
||||||
<div className="card-header">
|
<div className='card'>
|
||||||
<div className="d-flex justify-content-between">
|
<div className='card-header'>
|
||||||
<h3 className="card-title">
|
<div className='d-flex justify-content-between'>
|
||||||
<span>{t('general.texturePreview')}</span>
|
<h3 className='card-title'>
|
||||||
</h3>
|
<span>{t('general.texturePreview')}</span>
|
||||||
</div>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body"></div>
|
</div>
|
||||||
</div>
|
<div className='card-body'/>
|
||||||
)
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default ViewerSkeleton
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
@import '../fonts/minecraft.css';
|
@import '@/fonts/minecraft.css';
|
||||||
@import './avatar.css';
|
@import '@/styles/avatar.css';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
@ -1,37 +1,35 @@
|
||||||
import * as React from 'react'
|
import $ from 'jquery';
|
||||||
import * as ReactDOM from 'react-dom'
|
import React from 'react';
|
||||||
import $ from 'jquery'
|
import ReactDOM from 'react-dom';
|
||||||
import './scripts/app'
|
import {createRoot} from 'react-dom/client';
|
||||||
import routes from './scripts/route'
|
import routes from './scripts/route';
|
||||||
|
|
||||||
Object.assign(window, { React, ReactDOM, $ })
|
import './scripts/app';
|
||||||
|
|
||||||
const entry = document.querySelector('[href="#launch-cli"]')
|
// eslint-disable-next-line ts/naming-convention
|
||||||
entry?.addEventListener('click', async () => {
|
Object.assign(window, {React, ReactDOM, $});
|
||||||
const { launch } = await import('./scripts/cli')
|
|
||||||
launch()
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = routes.find((route) =>
|
const route = routes.find(route =>
|
||||||
new RegExp(`^${route.path}$`, 'i').test(blessing.route),
|
new RegExp(`^${route.path}$`, 'i').test(blessing.route));
|
||||||
)
|
|
||||||
if (route) {
|
if (route) {
|
||||||
if (route.module) {
|
if (route.module) {
|
||||||
Promise.all(route.module.map((m) => m()))
|
void Promise.all(route.module.map(async m => m()));
|
||||||
}
|
}
|
||||||
if (route.react) {
|
|
||||||
const Component = React.lazy(
|
if (route.react) {
|
||||||
route.react as () => Promise<{ default: React.ComponentType }>,
|
const Component = React.lazy(route.react as () => Promise<{default: React.ComponentType}>);
|
||||||
)
|
|
||||||
const Root = () => (
|
const container = typeof route.el === 'string'
|
||||||
<React.StrictMode>
|
? document.querySelector(route.el)
|
||||||
<React.Suspense fallback={route.frame?.() ?? ''}>
|
: null;
|
||||||
<Component />
|
|
||||||
</React.Suspense>
|
const root = createRoot(container!);
|
||||||
</React.StrictMode>
|
root.render((
|
||||||
)
|
<React.StrictMode>
|
||||||
const c =
|
<React.Suspense fallback={route.frame?.() ?? ''}>
|
||||||
typeof route.el === 'string' ? document.querySelector(route.el) : route.el
|
<Component/>
|
||||||
ReactDOM.render(<Root />, c)
|
</React.Suspense>
|
||||||
}
|
</React.StrictMode>
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import './init' // must be first
|
import {Tooltip} from 'bootstrap';
|
||||||
import 'admin-lte'
|
import '@popperjs/core';
|
||||||
import './extra'
|
import 'admin-lte';
|
||||||
import './i18n'
|
import './extra';
|
||||||
import './net'
|
import './i18n';
|
||||||
import './event'
|
import './net';
|
||||||
import './notification'
|
import './event';
|
||||||
import './emailVerification'
|
import './notification';
|
||||||
import './logout'
|
import './emailVerification';
|
||||||
import './darkMode'
|
import './logout';
|
||||||
|
import './darkMode';
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
$('[data-toggle="tooltip"]').tooltip()
|
[...document.querySelectorAll('[data-toggle="tooltip"]')].map(el => new Tooltip(el));
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
import React, { useEffect, useRef } from 'react'
|
|
||||||
import ReactDOM from 'react-dom'
|
|
||||||
import styled from '@emotion/styled'
|
|
||||||
import { Terminal } from 'xterm'
|
|
||||||
import { FitAddon } from 'xterm-addon-fit'
|
|
||||||
import { Shell, Stdio } from 'blessing-skin-shell'
|
|
||||||
import 'xterm/css/xterm.css'
|
|
||||||
import Draggable from 'react-draggable'
|
|
||||||
import * as event from './event'
|
|
||||||
import AptCommand from './cli/AptCommand'
|
|
||||||
import ClosetCommand from './cli/ClosetCommand'
|
|
||||||
import DnfCommand from './cli/DnfCommand'
|
|
||||||
import PacmanCommand from './cli/PacmanCommand'
|
|
||||||
import RmCommand from './cli/RmCommand'
|
|
||||||
import * as breakpoints from '@/styles/breakpoints'
|
|
||||||
|
|
||||||
let launched = false
|
|
||||||
|
|
||||||
const TerminalContainer = styled.div`
|
|
||||||
z-index: 1040;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 7vh;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
background-color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
${breakpoints.greaterThan(breakpoints.Breakpoint.xl)} {
|
|
||||||
left: 25vw;
|
|
||||||
width: 50vw;
|
|
||||||
height: 50vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
${breakpoints.between(breakpoints.Breakpoint.md, breakpoints.Breakpoint.xl)} {
|
|
||||||
left: 5vw;
|
|
||||||
width: 90vw;
|
|
||||||
height: 40vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
${breakpoints.lessThan(breakpoints.Breakpoint.md)} {
|
|
||||||
left: 1vw;
|
|
||||||
width: 98vw;
|
|
||||||
height: 35vh;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const TerminalWindow: React.FC<{ onClose(): void }> = (props) => {
|
|
||||||
const mount = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = mount.current
|
|
||||||
if (!el) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const terminal = new Terminal()
|
|
||||||
const fitAddon = new FitAddon()
|
|
||||||
terminal.loadAddon(fitAddon)
|
|
||||||
terminal.setOption(
|
|
||||||
'fontFamily',
|
|
||||||
'Monaco, Consolas, "Roboto Mono", "Noto Sans", "Droid Sans Mono"',
|
|
||||||
)
|
|
||||||
terminal.open(el)
|
|
||||||
fitAddon.fit()
|
|
||||||
|
|
||||||
const programs = new Map<string, (stdio: Stdio, args: string[]) => void>()
|
|
||||||
programs.set('apt', AptCommand)
|
|
||||||
programs.set('closet', ClosetCommand)
|
|
||||||
programs.set('dnf', DnfCommand)
|
|
||||||
programs.set('pacman', PacmanCommand)
|
|
||||||
programs.set('rm', RmCommand)
|
|
||||||
event.emit('registerCLIPrograms', programs)
|
|
||||||
|
|
||||||
const shell = new Shell(terminal)
|
|
||||||
programs.forEach((program, name) => {
|
|
||||||
shell.addExternal(name, program)
|
|
||||||
})
|
|
||||||
|
|
||||||
const originalLogger = console.log
|
|
||||||
console.log = (data: string, ...args: any[]) => {
|
|
||||||
const stack = new Error().stack
|
|
||||||
if (stack?.includes('outputHelp')) {
|
|
||||||
terminal.writeln(data.replace(/\n/g, '\r\n'))
|
|
||||||
} else {
|
|
||||||
originalLogger(data, ...args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const unbindData = terminal.onData((e) => shell.input(e))
|
|
||||||
const unbindKey = terminal.onKey((e) =>
|
|
||||||
event.emit('terminalKeyPress', e.key),
|
|
||||||
)
|
|
||||||
launched = true
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unbindData.dispose()
|
|
||||||
unbindKey.dispose()
|
|
||||||
shell.free()
|
|
||||||
fitAddon.dispose()
|
|
||||||
terminal.dispose()
|
|
||||||
console.log = originalLogger
|
|
||||||
launched = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Draggable handle=".card-header">
|
|
||||||
<TerminalContainer className="card">
|
|
||||||
<div className="card-header">
|
|
||||||
<div className="d-flex justify-content-between">
|
|
||||||
<h4 className="card-title d-flex align-items-center">
|
|
||||||
Blessing Skin Shell
|
|
||||||
</h4>
|
|
||||||
<button className="btn btn-default" onClick={props.onClose}>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card-body p-2" ref={mount}></div>
|
|
||||||
</TerminalContainer>
|
|
||||||
</Draggable>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function launch() {
|
|
||||||
if (launched) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = document.createElement('div')
|
|
||||||
document.body.appendChild(container)
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
ReactDOM.unmountComponentAtNode(container)
|
|
||||||
container.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
ReactDOM.render(<TerminalWindow onClose={handleClose} />, container)
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
rules:
|
|
||||||
'@typescript-eslint/no-empty-function': off
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import type { Stdio } from 'blessing-skin-shell'
|
|
||||||
import cac from 'cac'
|
|
||||||
import { install, remove } from './pluginManager'
|
|
||||||
|
|
||||||
export default async function apt(stdio: Stdio, args: string[]) {
|
|
||||||
const program = cac('apt')
|
|
||||||
program.help()
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('install <plugin>', 'install a new plugin')
|
|
||||||
.action((plugin: string) => install(plugin, stdio))
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('upgrade <plugin>', 'upgrade an existed plugin')
|
|
||||||
.action((plugin: string) => install(plugin, stdio))
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('remove <plugin>', 'remove a plugin')
|
|
||||||
.action((plugin: string) => remove(plugin, stdio))
|
|
||||||
|
|
||||||
program.parse(['', ''].concat(args), { run: false })
|
|
||||||
await program.runMatchedCommand()
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import type { Stdio } from 'blessing-skin-shell'
|
|
||||||
import cac from 'cac'
|
|
||||||
import * as fetch from '../net'
|
|
||||||
import type { User, Texture } from '../types'
|
|
||||||
|
|
||||||
type Response = fetch.ResponseBody<{ user: User; texture: Texture }>
|
|
||||||
|
|
||||||
export default async function closet(stdio: Stdio, args: string[]) {
|
|
||||||
const program = cac('closet')
|
|
||||||
program.help()
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('add <uid> <tid>', "add texture to someone's closet")
|
|
||||||
.action(async (uid: string, tid: string) => {
|
|
||||||
const { code, data } = await fetch.post<Response>(
|
|
||||||
`/admin/closet/${uid}`,
|
|
||||||
{ tid },
|
|
||||||
)
|
|
||||||
if (code === 0) {
|
|
||||||
const { texture, user } = data
|
|
||||||
stdio.println(
|
|
||||||
`Texture "${texture.name}" was added to user ${user.nickname}'s closet.`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
stdio.println('Error occurred.')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
program
|
|
||||||
.command('remove <uid> <tid>', "remove texture from someone's closet")
|
|
||||||
.action(async (uid: string, tid: string) => {
|
|
||||||
const { code, data } = await fetch.del<Response>(`/admin/closet/${uid}`, {
|
|
||||||
tid,
|
|
||||||
})
|
|
||||||
if (code === 0) {
|
|
||||||
const { texture, user } = data
|
|
||||||
stdio.println(
|
|
||||||
`Texture "${texture.name}" was removed from user ${user.nickname}'s closet.`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
stdio.println('Error occurred.')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
program.parse(['', ''].concat(args), { run: false })
|
|
||||||
await program.runMatchedCommand()
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import type { Stdio } from 'blessing-skin-shell'
|
|
||||||
import cac from 'cac'
|
|
||||||
import { install, remove } from './pluginManager'
|
|
||||||
|
|
||||||
export default async function dnf(stdio: Stdio, args: string[]) {
|
|
||||||
const program = cac('dnf')
|
|
||||||
program.help()
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('install <plugin>', 'install a new plugin')
|
|
||||||
.action((plugin: string) => install(plugin, stdio))
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('upgrade <plugin>', 'upgrade an existed plugin')
|
|
||||||
.action((plugin: string) => install(plugin, stdio))
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('remove <plugin>', 'remove a plugin')
|
|
||||||
.action((plugin: string) => remove(plugin, stdio))
|
|
||||||
|
|
||||||
program.parse(['', ''].concat(args), { run: false })
|
|
||||||
await program.runMatchedCommand()
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import type { Stdio } from 'blessing-skin-shell'
|
|
||||||
import cac from 'cac'
|
|
||||||
import { install, remove } from './pluginManager'
|
|
||||||
|
|
||||||
type Options = {
|
|
||||||
sync?: string
|
|
||||||
remove?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function pacman(stdio: Stdio, args: string[]) {
|
|
||||||
if (args.length === 0) {
|
|
||||||
stdio.println('error: no operation specified (use -h for help)')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const program = cac('pacman')
|
|
||||||
program.help()
|
|
||||||
|
|
||||||
program.option('-S, --sync <plugin>', 'install or upgrade a plugin')
|
|
||||||
program.option('-R, --remove <plugin>', 'remove a plugin')
|
|
||||||
|
|
||||||
const { options } = program.parse(['', ''].concat(args), { run: false })
|
|
||||||
|
|
||||||
const opts: Options = options
|
|
||||||
/* istanbul ignore else */
|
|
||||||
if (opts.sync) {
|
|
||||||
await install(opts.sync, stdio)
|
|
||||||
} else if (opts.remove) {
|
|
||||||
await remove(opts.remove, stdio)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import type { Stdio } from 'blessing-skin-shell'
|
|
||||||
import cac from 'cac'
|
|
||||||
import * as fetch from '../net'
|
|
||||||
|
|
||||||
type Options = {
|
|
||||||
force?: boolean
|
|
||||||
recursive?: boolean
|
|
||||||
help?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function rm(stdio: Stdio, args: string[]) {
|
|
||||||
const program = cac('rm')
|
|
||||||
program.help()
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('<file>')
|
|
||||||
.option(
|
|
||||||
'-f, --force',
|
|
||||||
'ignore nonexistent files and arguments, never prompt',
|
|
||||||
)
|
|
||||||
.option(
|
|
||||||
'-r, --recursive',
|
|
||||||
'remove directories and their contents recursively',
|
|
||||||
)
|
|
||||||
.option('--no-preserve-root', "do not treat '/' specially")
|
|
||||||
|
|
||||||
const opts: Options = program.parse(['', ''].concat(args), {
|
|
||||||
run: false,
|
|
||||||
}).options
|
|
||||||
const path = program.args[0]
|
|
||||||
|
|
||||||
if (!path && !opts.help) {
|
|
||||||
stdio.println('rm: missing operand')
|
|
||||||
stdio.println("Try 'rm --help' for more information.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.force && opts.recursive && path?.startsWith('/')) {
|
|
||||||
await fetch.post('/admin/resource?clear-cache')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import type { Stdio } from 'blessing-skin-shell'
|
|
||||||
import spinners from 'cli-spinners/spinners.json'
|
|
||||||
|
|
||||||
const { dots } = spinners
|
|
||||||
|
|
||||||
export class Spinner {
|
|
||||||
private timerId = 0
|
|
||||||
private index = 0
|
|
||||||
|
|
||||||
constructor(private stdio: Stdio) {}
|
|
||||||
|
|
||||||
start(message = '') {
|
|
||||||
this.timerId = window.setInterval(() => {
|
|
||||||
this.index += 1
|
|
||||||
this.index %= dots.frames.length
|
|
||||||
|
|
||||||
this.stdio.reset()
|
|
||||||
this.stdio.print(`${dots.frames[this.index]} ${message}`)
|
|
||||||
}, dots.interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(message = '') {
|
|
||||||
clearInterval(this.timerId)
|
|
||||||
this.stdio.reset()
|
|
||||||
this.stdio.println(message)
|
|
||||||
this.stdio.print('\u001B[?25h')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import type { Stdio } from 'blessing-skin-shell'
|
|
||||||
import * as event from '../event'
|
|
||||||
|
|
||||||
/* istanbul ignore next */
|
|
||||||
export function hackStdin() {
|
|
||||||
if (process.env.NODE_ENV === 'test') {
|
|
||||||
return process.stdin
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
return {
|
|
||||||
on(eventName: string, handler: (str: string, key: string) => void) {
|
|
||||||
if (eventName === 'keypress') {
|
|
||||||
this._off = event.on('terminalKeyPress', (key: string) => {
|
|
||||||
handler(key, key)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isTTY: true,
|
|
||||||
setRawMode() {},
|
|
||||||
removeListener() {
|
|
||||||
this._off()
|
|
||||||
},
|
|
||||||
} as NodeJS.ReadStream & { _off(): void }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* istanbul ignore next */
|
|
||||||
export function hackStdout(stdio: Stdio) {
|
|
||||||
return {
|
|
||||||
write(msg: string) {
|
|
||||||
stdio.print(msg.replace(/\n/g, '\r\n'))
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
} as NodeJS.WriteStream
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import type { Stdio } from 'blessing-skin-shell'
|
|
||||||
import prompts from 'prompts'
|
|
||||||
import * as fetch from '../net'
|
|
||||||
import { hackStdout, hackStdin } from './configureStdio'
|
|
||||||
import { Spinner } from './Spinner'
|
|
||||||
|
|
||||||
export async function install(plugin: string, stdio: Stdio) {
|
|
||||||
const spinner = new Spinner(stdio)
|
|
||||||
spinner.start('Installing plugin...')
|
|
||||||
|
|
||||||
const { message, data } = await fetch.post<
|
|
||||||
fetch.ResponseBody<{ reason?: string[] } | undefined>
|
|
||||||
>('/admin/plugins/market/download', { name: plugin })
|
|
||||||
|
|
||||||
spinner.stop(` ${message}`)
|
|
||||||
const reasons = data?.reason
|
|
||||||
if (reasons) {
|
|
||||||
stdio.println(reasons.map((reason) => `- ${reason}`).join('\r\n'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function remove(plugin: string, stdio: Stdio) {
|
|
||||||
const { confirm }: { confirm: boolean } = await prompts({
|
|
||||||
name: 'confirm',
|
|
||||||
type: 'confirm',
|
|
||||||
message: `Are you sure to remove plugin "${plugin}"?`,
|
|
||||||
stdin: hackStdin(),
|
|
||||||
stdout: hackStdout(stdio),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!confirm) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const spinner = new Spinner(stdio)
|
|
||||||
spinner.start('Uninstalling plugin...')
|
|
||||||
|
|
||||||
const { message } = await fetch.post<fetch.ResponseBody>(
|
|
||||||
'/admin/plugins/manage',
|
|
||||||
{ action: 'delete', name: plugin },
|
|
||||||
)
|
|
||||||
spinner.stop(` ${message}`)
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export function emitKeypressEvents() {}
|
|
||||||
|
|
||||||
export function createInterface() {
|
|
||||||
return {
|
|
||||||
pause() {},
|
|
||||||
resume() {},
|
|
||||||
close() {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import * as React from 'react'
|
import DarkModeButton from '@/components/DarkModeButton';
|
||||||
import * as ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom';
|
||||||
import DarkModeButton from '@/components/DarkModeButton'
|
|
||||||
|
|
||||||
const el = document.querySelector('#toggle-dark-mode')
|
const element = document.querySelector('#toggle-dark-mode');
|
||||||
if (el) {
|
if (element) {
|
||||||
const initMode = document.body.classList.contains('dark-mode')
|
const initMode = document.body.classList.contains('dark-mode');
|
||||||
ReactDOM.render(<DarkModeButton initMode={initMode} />, el)
|
ReactDOM.render(<DarkModeButton initMode={initMode}/>, element);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import React from 'react'
|
import EmailVerification from '@/views/widgets/EmailVerification';
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom';
|
||||||
import EmailVerification from '@/views/widgets/EmailVerification'
|
|
||||||
|
|
||||||
const container = document.querySelector('#email-verification')
|
const container = document.querySelector('#email-verification');
|
||||||
|
|
||||||
if (blessing.extra.unverified && container) {
|
if (blessing.extra.unverified && container) {
|
||||||
ReactDOM.render(<EmailVerification />, container)
|
ReactDOM.render(<EmailVerification/>, container);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
const bus = new Map<string | symbol, Set<(...args: any[]) => void>>();
|
||||||
const bus = new Map<string | symbol, Set<CallableFunction>>()
|
|
||||||
|
|
||||||
export function on(event: string | symbol, listener: CallableFunction) {
|
export function on(event: string | symbol, listener: (...args: any[]) => void) {
|
||||||
if (!bus.has(event)) {
|
if (!bus.has(event)) {
|
||||||
bus.set(event, new Set())
|
bus.set(event, new Set());
|
||||||
}
|
}
|
||||||
const listeners = bus.get(event)!
|
|
||||||
listeners.add(listener)
|
|
||||||
|
|
||||||
return () => {
|
const listeners = bus.get(event)!;
|
||||||
listeners.delete(listener)
|
listeners.add(listener);
|
||||||
}
|
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emit(event: string | symbol, payload?: unknown) {
|
export function emit(event: string | symbol, payload?: unknown) {
|
||||||
bus.get(event)?.forEach((listener) => listener(payload))
|
bus.get(event)?.forEach(listener => {
|
||||||
|
listener(payload);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
blessing.event = { on, emit }
|
blessing.event = {on, emit};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
export function getExtraData(): Record<string, any> {
|
export function getExtraData(): Record<string, any> {
|
||||||
const jsonElement = document.querySelector('#blessing-extra')
|
const jsonElement = document.querySelector('#blessing-extra');
|
||||||
/* istanbul ignore next */
|
|
||||||
if (jsonElement) {
|
if (jsonElement) {
|
||||||
return JSON.parse(jsonElement.textContent ?? '{}')
|
return JSON.parse(jsonElement.textContent ?? '{}');
|
||||||
} else {
|
}
|
||||||
return {}
|
|
||||||
}
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
blessing.extra = getExtraData()
|
blessing.extra = getExtraData();
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,24 @@
|
||||||
import { getExtraData } from './extra'
|
import {getExtraData} from './extra';
|
||||||
|
|
||||||
export function scrollHander() {
|
export function scrollHander() {
|
||||||
const header = document.querySelector('.navbar')
|
const header = document.querySelector('.navbar');
|
||||||
/* istanbul ignore else */
|
/* istanbul ignore else */
|
||||||
if (header) {
|
if (header) {
|
||||||
window.addEventListener('scroll', () => {
|
window.addEventListener('scroll', () => {
|
||||||
if (window.scrollY >= (window.innerHeight * 2) / 3) {
|
if (window.scrollY >= (window.innerHeight * 2) / 3) {
|
||||||
header.classList.remove('transparent')
|
header.classList.remove('transparent');
|
||||||
} else {
|
} else {
|
||||||
header.classList.add('transparent')
|
header.classList.add('transparent');
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* istanbul ignore next */
|
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
const { transparent_navbar } = getExtraData() as {
|
const {transparent_navbar} = getExtraData() as {
|
||||||
transparent_navbar: boolean
|
transparent_navbar: boolean;
|
||||||
}
|
};
|
||||||
if (transparent_navbar) {
|
if (transparent_navbar) {
|
||||||
window.addEventListener('load', scrollHander)
|
window.addEventListener('load', scrollHander);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { useState, useEffect } from 'react'
|
import {useEffect, useState} from 'react';
|
||||||
|
|
||||||
export default function useBlessingExtra<T>(key: string, defaultValue?: T): T {
|
export default function useBlessingExtra<T>(key: string, defaultValue?: T): T {
|
||||||
const [value, setValue] = useState<T>(defaultValue!)
|
const [value, setValue] = useState<T>(defaultValue!);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue(blessing.extra[key] as T)
|
setValue(blessing.extra[key] as T);
|
||||||
}, [key])
|
}, [key]);
|
||||||
|
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useEffect } from 'react'
|
import {useEffect} from 'react';
|
||||||
import { emit } from '../event'
|
import {emit} from '../event';
|
||||||
|
|
||||||
export default function useEmitMounted() {
|
export default function useEmitMounted() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
emit('mounted')
|
emit('mounted');
|
||||||
}, [])
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { useState, useEffect } from 'react'
|
import {useEffect, useState} from 'react';
|
||||||
|
|
||||||
export default function useIsLargeScreen() {
|
export default function useIsLargeScreen() {
|
||||||
const [isLarge, setIsLarge] = useState(false)
|
const [isLarge, setIsLarge] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.innerWidth >= 992) {
|
if (window.innerWidth >= 992) {
|
||||||
setIsLarge(true)
|
setIsLarge(true);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return isLarge
|
return isLarge;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import { useEffect, useRef } from 'react'
|
import {useEffect, useRef} from 'react';
|
||||||
|
|
||||||
export default function useMount(selector: string): HTMLElement | null {
|
export default function useMount(selector: string): HTMLElement | undefined {
|
||||||
const container = useRef<HTMLDivElement | null>(null)
|
const container = useRef<HTMLDivElement | undefined>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mount = document.querySelector(selector)!
|
const mount = document.querySelector(selector)!;
|
||||||
const div = document.createElement('div')
|
const div = document.createElement('div');
|
||||||
container.current = div
|
container.current = div;
|
||||||
|
|
||||||
mount.appendChild(div)
|
mount.append(div);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mount.removeChild(div)
|
div.remove();
|
||||||
container.current = null
|
container.current = null;
|
||||||
}
|
};
|
||||||
}, [selector])
|
}, [selector]);
|
||||||
|
|
||||||
return container.current
|
return container.current;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,27 @@
|
||||||
import { useState, useEffect } from 'react'
|
import {useEffect, useState} from 'react';
|
||||||
import * as fetch from '../net'
|
import * as fetch from '../net';
|
||||||
import { Texture, TextureType } from '../types'
|
import {type Texture, TextureType} from '../types';
|
||||||
|
|
||||||
export default function useTexture() {
|
export default function useTexture() {
|
||||||
const [tid, setTid] = useState(0)
|
const [tid, setTid] = useState(0);
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('');
|
||||||
const [type, setType] = useState(TextureType.Steve)
|
const [type, setType] = useState(TextureType.Steve);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tid <= 0) {
|
if (tid <= 0) {
|
||||||
setUrl('')
|
setUrl('');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTexture = async () => {
|
const getTexture = async () => {
|
||||||
const { hash, type } = await fetch.get<Texture>(`/skinlib/info/${tid}`)
|
const {hash, type} = await fetch.get<Texture>(`/skinlib/info/${tid}`);
|
||||||
|
|
||||||
setUrl(`${blessing.base_url}/textures/${hash}`)
|
setUrl(`${blessing.base_url}/textures/${hash}`);
|
||||||
setType(type)
|
setType(type);
|
||||||
}
|
};
|
||||||
getTexture()
|
|
||||||
}, [tid])
|
|
||||||
|
|
||||||
return [{ url, type }, setTid] as const
|
getTexture();
|
||||||
|
}, [tid]);
|
||||||
|
|
||||||
|
return [{url, type}, setTid] as const;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import TWEEN from '@tweenjs/tween.js';
|
||||||
import TWEEN from '@tweenjs/tween.js'
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
|
||||||
export default function useTween<T = any>(initialValue: T) {
|
export default function useTween<T = any>(initialValue: T) {
|
||||||
const [value, setValue] = useState<T>(initialValue)
|
const [value, setValue] = useState<T>(initialValue);
|
||||||
const ref = useRef<T>(value)
|
const reference = useRef<T>(value);
|
||||||
const [dest, setDest] = useState<T>(initialValue)
|
const [destination, setDestination] = useState<T>(initialValue);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function animate() {
|
function animate() {
|
||||||
requestAnimationFrame(animate)
|
requestAnimationFrame(animate);
|
||||||
TWEEN.update()
|
TWEEN.update();
|
||||||
setValue(ref.current)
|
setValue(reference.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tween = new TWEEN.Tween(ref)
|
const tween = new TWEEN.Tween(reference);
|
||||||
tween.to({ current: dest }, 1000).start()
|
tween.to({current: destination}, 1000).start();
|
||||||
animate()
|
animate();
|
||||||
}, [dest])
|
}, [destination]);
|
||||||
|
|
||||||
return [value, setDest] as const
|
return [value, setDestination] as const;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,34 @@
|
||||||
interface I18nTable {
|
type I18nTable = {
|
||||||
[key: string]: string | I18nTable | undefined
|
[key: string]: string | I18nTable | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function t(
|
||||||
|
key: string,
|
||||||
|
parameters: Record<string, string> = Object.create(null) as Record<string, string>,
|
||||||
|
): string {
|
||||||
|
const segments = key.split('.');
|
||||||
|
let temporary = blessing.i18n as I18nTable | undefined;
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
const middle = temporary?.[segment];
|
||||||
|
if (middle === undefined) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof middle === 'string') {
|
||||||
|
result = middle;
|
||||||
|
} else {
|
||||||
|
temporary = middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const slot of Object.keys(parameters)) {
|
||||||
|
result = result.replace(`:${slot}`, parameters[slot] ?? `%{${slot}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function t(key: string, parameters = Object.create(null)): string {
|
Object.assign(window, {trans: t});
|
||||||
const segments = key.split('.')
|
Object.assign(blessing, {t});
|
||||||
let temp = blessing.i18n as I18nTable | undefined
|
|
||||||
let result = ''
|
|
||||||
|
|
||||||
for (const segment of segments) {
|
|
||||||
/* istanbul ignore next */
|
|
||||||
const middle = temp?.[segment]
|
|
||||||
if (!middle) {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
if (typeof middle === 'string') {
|
|
||||||
result = middle
|
|
||||||
} else {
|
|
||||||
temp = middle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.keys(parameters).forEach(
|
|
||||||
(slot) => (result = result.replace(`:${slot}`, parameters[slot])),
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(window, { trans: t })
|
|
||||||
Object.assign(blessing, { t })
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
declare let __webpack_public_path__: string
|
declare let __webpack_public_path__: string;
|
||||||
declare const __blessing_public_path__: string
|
declare const __blessing_public_path__: string;
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (import.meta.env.NODE_ENV === 'development') {
|
||||||
__webpack_public_path__ = __blessing_public_path__
|
__webpack_public_path__ = __blessing_public_path__;
|
||||||
} else {
|
} else {
|
||||||
const link = document.querySelector<HTMLLinkElement>('link#cdn-host')
|
const link = document.querySelector<HTMLLinkElement>('link#cdn-host');
|
||||||
const base = link?.href ?? blessing.base_url
|
const base = link?.href ?? blessing.base_url;
|
||||||
__webpack_public_path__ = `${base}/app/`
|
__webpack_public_path__ = `${base}/app/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {}
|
export {};
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
import { post } from './net'
|
import {t} from './i18n';
|
||||||
import { t } from './i18n'
|
import {post} from './net';
|
||||||
import { showModal } from './notify'
|
import {showModal} from './notify';
|
||||||
import urls from './urls'
|
import urls from './urls';
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
try {
|
try {
|
||||||
await showModal({
|
await showModal({
|
||||||
text: t('general.confirmLogout'),
|
text: t('general.confirmLogout'),
|
||||||
center: true,
|
center: true,
|
||||||
})
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await post(urls.auth.logout())
|
await post(urls.auth.logout());
|
||||||
window.location.href = blessing.base_url
|
window.location.href = blessing.base_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
const button = document.querySelector('#logout-button')
|
const button = document.querySelector('#logout-button');
|
||||||
/* istanbul ignore next */
|
|
||||||
button?.addEventListener('click', logout)
|
button?.addEventListener('click', logout);
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,26 @@
|
||||||
import React from 'react'
|
import {createRoot} from 'react-dom/client';
|
||||||
import ReactDOM from 'react-dom'
|
import Modal, {type ModalOptions, type ModalResult} from '../components/Modal';
|
||||||
import Modal, { ModalOptions, ModalResult } from '../components/Modal'
|
|
||||||
|
|
||||||
export function showModal(options: ModalOptions = {}): Promise<ModalResult> {
|
export async function showModal(options: ModalOptions = {}): Promise<ModalResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div');
|
||||||
document.body.appendChild(container)
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
ReactDOM.unmountComponentAtNode(container)
|
root.unmount();
|
||||||
document.body.removeChild(container)
|
container.remove();
|
||||||
}
|
};
|
||||||
|
|
||||||
ReactDOM.render(
|
root.render((
|
||||||
<Modal
|
<Modal
|
||||||
{...options}
|
{...options}
|
||||||
show
|
show
|
||||||
center
|
center
|
||||||
onConfirm={resolve}
|
onConfirm={resolve}
|
||||||
onDismiss={reject}
|
onDismiss={reject}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
/>,
|
/>
|
||||||
container,
|
));
|
||||||
)
|
});
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,159 +1,162 @@
|
||||||
import { emit } from './event'
|
import {emit} from './event';
|
||||||
import { showModal } from './notify'
|
import {t} from './i18n';
|
||||||
import { t } from './i18n'
|
import {showModal} from './notify';
|
||||||
|
|
||||||
export interface ResponseBody<T = null> {
|
export type ResponseBody<T = undefined> = {
|
||||||
code: number
|
code: number;
|
||||||
message: string
|
message: string;
|
||||||
data: T extends null ? never : T
|
data: T extends undefined ? never : T;
|
||||||
}
|
};
|
||||||
|
|
||||||
class HTTPError extends Error {
|
class HTTPError extends Error {
|
||||||
response: Response
|
response: Response;
|
||||||
|
|
||||||
constructor(message: string, response: Response) {
|
constructor(message: string, response: Response) {
|
||||||
super(message)
|
super(message);
|
||||||
this.response = response
|
this.response = response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const empty = Object.create(null)
|
const empty: Record<string, never> = Object.create(null);
|
||||||
export const init: RequestInit = {
|
export const init: RequestInit = {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: new Headers({
|
headers: new Headers({
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
}),
|
}),
|
||||||
}
|
};
|
||||||
|
|
||||||
function retrieveToken() {
|
function retrieveToken() {
|
||||||
const csrfField = document.querySelector<HTMLMetaElement>(
|
const csrfField = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]');
|
||||||
'meta[name="csrf-token"]',
|
|
||||||
)
|
return csrfField?.content || '';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
||||||
return csrfField?.content || ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function walkFetch(request: Request): Promise<any> {
|
export async function walkFetch(request: Request): Promise<any> {
|
||||||
request.headers.set('X-CSRF-TOKEN', retrieveToken())
|
request.headers.set('X-CSRF-TOKEN', retrieveToken());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(request)
|
const response = await fetch(request);
|
||||||
const cloned = response.clone()
|
const cloned = response.clone();
|
||||||
const body =
|
const body
|
||||||
response.headers.get('Content-Type') === 'application/json'
|
= response.headers.get('Content-Type') === 'application/json'
|
||||||
? await response.json()
|
? await response.json()
|
||||||
: await response.text()
|
: await response.text();
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return body
|
return body;
|
||||||
}
|
}
|
||||||
let message: string = body.message
|
|
||||||
|
|
||||||
if (response.status === 422) {
|
let {message} = body;
|
||||||
// Process validation errors from Laravel.
|
|
||||||
const {
|
|
||||||
errors,
|
|
||||||
}: {
|
|
||||||
message: string
|
|
||||||
errors: { [field: string]: string[] }
|
|
||||||
} = body
|
|
||||||
return {
|
|
||||||
code: 1,
|
|
||||||
message: Object.keys(errors).map((field) => errors[field]![0])[0],
|
|
||||||
}
|
|
||||||
} else if (response.status === 419) {
|
|
||||||
return showModal({
|
|
||||||
mode: 'alert',
|
|
||||||
text: t('general.csrf'),
|
|
||||||
})
|
|
||||||
} else if (response.status === 403 || response.status === 400) {
|
|
||||||
return showModal({
|
|
||||||
mode: 'alert',
|
|
||||||
text: message,
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.exception && Array.isArray(body.trace)) {
|
if (response.status === 422) {
|
||||||
const trace = (body.trace as Array<{ file: string; line: number }>)
|
// Process validation errors from Laravel.
|
||||||
.map((t, i) => `[${i + 1}] ${t.file}#L${t.line}`)
|
const {
|
||||||
.join('<br>')
|
errors,
|
||||||
message = `${message}<br><details>${trace}</details>`
|
}: {
|
||||||
}
|
message: string;
|
||||||
|
errors: Record<string, string[]>;
|
||||||
|
} = body;
|
||||||
|
return {
|
||||||
|
code: 1,
|
||||||
|
message: Object.keys(errors).map(field => errors[field][0])[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
throw new HTTPError(message || body, cloned)
|
if (response.status === 419) {
|
||||||
} catch (error: any) {
|
return await showModal({
|
||||||
emit('fetchError', error)
|
mode: 'alert',
|
||||||
await showModal({
|
text: t('general.csrf'),
|
||||||
mode: 'alert',
|
});
|
||||||
title: t('general.fatalError'),
|
}
|
||||||
dangerousHTML: error.message,
|
|
||||||
type: 'danger',
|
|
||||||
okButtonType: 'outline-light',
|
|
||||||
})
|
|
||||||
|
|
||||||
return { code: -1, message: t('general.fatalError') }
|
if (response.status === 403 || response.status === 400) {
|
||||||
}
|
return await showModal({
|
||||||
|
mode: 'alert',
|
||||||
|
text: message,
|
||||||
|
type: 'warning',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.exception && Array.isArray(body.trace)) {
|
||||||
|
const trace = (body.trace as Array<{file: string; line: number}>)
|
||||||
|
.map((t, i) => `[${i + 1}] ${t.file}#L${t.line}`)
|
||||||
|
.join('<br>');
|
||||||
|
message = `${message}<br><details>${trace}</details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HTTPError(message || String(body), cloned);
|
||||||
|
} catch (error: any) {
|
||||||
|
emit('fetchError', error);
|
||||||
|
await showModal({
|
||||||
|
mode: 'alert',
|
||||||
|
title: t('general.fatalError'),
|
||||||
|
dangerousHTML: error.message,
|
||||||
|
type: 'danger',
|
||||||
|
okButtonType: 'outline-light',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {code: -1, message: t('general.fatalError')};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get<T = any>(url: string, params = empty): Promise<T> {
|
export async function get<T = any>(url: string, parameters: Record<string, string> | URLSearchParams = empty): Promise<T> {
|
||||||
emit('beforeFetch', {
|
emit('beforeFetch', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url,
|
url,
|
||||||
data: params,
|
data: parameters,
|
||||||
})
|
});
|
||||||
|
|
||||||
const qs = new URLSearchParams(params).toString()
|
const qs = new URLSearchParams(parameters).toString();
|
||||||
|
|
||||||
return walkFetch(new Request(`${blessing.base_url}${url}?${qs}`, init))
|
return walkFetch(new Request(`${blessing.base_url}${url}?${qs}`, init));
|
||||||
}
|
}
|
||||||
|
|
||||||
function nonGet<T = any>(
|
async function nonGet<T = any>(
|
||||||
method: string,
|
method: string,
|
||||||
url: string,
|
url: string,
|
||||||
data?: FormData | Record<string, unknown>,
|
data?: FormData | Record<string, unknown>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
emit('beforeFetch', {
|
emit('beforeFetch', {
|
||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
url,
|
url,
|
||||||
data,
|
data,
|
||||||
})
|
});
|
||||||
|
|
||||||
const request = new Request(`${blessing.base_url}${url}`, {
|
const request = new Request(`${blessing.base_url}${url}`, {
|
||||||
body: data instanceof FormData ? data : JSON.stringify(data),
|
body: data instanceof FormData ? data : JSON.stringify(data),
|
||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
...init,
|
...init,
|
||||||
})
|
});
|
||||||
if (!(data instanceof FormData)) {
|
if (!(data instanceof FormData)) {
|
||||||
request.headers.set('Content-Type', 'application/json')
|
request.headers.set('Content-Type', 'application/json');
|
||||||
}
|
}
|
||||||
|
|
||||||
return walkFetch(request)
|
return walkFetch(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function post<T = any>(
|
export async function post<T = any>(
|
||||||
url: string,
|
url: string,
|
||||||
data?: FormData | Record<string, unknown>,
|
data?: FormData | Record<string, unknown>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return nonGet<T>('POST', url, data)
|
return nonGet<T>('POST', url, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function put<T = any>(
|
export async function put<T = any>(
|
||||||
url: string,
|
url: string,
|
||||||
data?: FormData | Record<string, unknown>,
|
data?: FormData | Record<string, unknown>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return nonGet<T>('PUT', url, data)
|
return nonGet<T>('PUT', url, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function del<T = any>(
|
export async function del<T = any>(
|
||||||
url: string,
|
url: string,
|
||||||
data?: FormData | Record<string, unknown>,
|
data?: FormData | Record<string, unknown>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return nonGet<T>('DELETE', url, data)
|
return nonGet<T>('DELETE', url, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
blessing.fetch = {
|
blessing.fetch = {
|
||||||
get,
|
get,
|
||||||
post,
|
post,
|
||||||
put,
|
put,
|
||||||
del,
|
del,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import React from 'react'
|
import NotificationsList from '@/views/widgets/NotificationsList';
|
||||||
import ReactDOM from 'react-dom'
|
import {createRoot} from 'react-dom/client';
|
||||||
import NotificationsList from '@/views/widgets/NotificationsList'
|
|
||||||
|
|
||||||
const container = document.querySelector('[data-notifications]')
|
const container = document.querySelector('[data-notifications]');
|
||||||
if (container) {
|
if (container) {
|
||||||
ReactDOM.render(<NotificationsList />, container)
|
createRoot(container).render(<NotificationsList/>);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
import { showModal } from './modal'
|
import {showModal} from './modal';
|
||||||
import { Toast } from './toast'
|
import {Toast} from './toast';
|
||||||
|
|
||||||
export const toast = new Toast()
|
export const toast = new Toast();
|
||||||
|
|
||||||
/* istanbul ignore next */
|
if (import.meta.env.NODE_ENV === 'test') {
|
||||||
if (process.env.NODE_ENV === 'test') {
|
afterEach(() => {
|
||||||
afterEach(() => {
|
toast.clear();
|
||||||
toast.clear()
|
});
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(blessing, { notify: { showModal, toast } })
|
Object.assign(blessing, {notify: {showModal, toast}});
|
||||||
|
|
||||||
export { showModal } from './modal'
|
export {showModal} from './modal';
|
||||||
|
|
|
||||||
|
|
@ -1,118 +1,116 @@
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
path: 'user',
|
path: 'user',
|
||||||
react: () => import('../views/user/Dashboard'),
|
react: async () => import('../views/user/Dashboard'),
|
||||||
el: '#usage-box',
|
el: '#usage-box',
|
||||||
frame: () => (
|
frame: () => (
|
||||||
<div className="card card-primary card-outline">
|
<div className='card card-primary card-outline'>
|
||||||
<div className="card-header"> </div>
|
<div className='card-header'> </div>
|
||||||
<div className="card-body"></div>
|
<div className='card-body'/>
|
||||||
<div className="card-footer"> </div>
|
<div className='card-footer'> </div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'user/closet',
|
path: 'user/closet',
|
||||||
react: () => import('../views/user/Closet'),
|
react: async () => import('../views/user/Closet'),
|
||||||
el: '#closet-list',
|
el: '#closet-list',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'user/player',
|
path: 'user/player',
|
||||||
react: () => import('../views/user/Players'),
|
react: async () => import('../views/user/Players'),
|
||||||
el: '#players-list',
|
el: '#players-list',
|
||||||
frame: () => (
|
frame: () => (
|
||||||
<div className="card">
|
<div className='card'>
|
||||||
<div className="card-header"> </div>
|
<div className='card-header'> </div>
|
||||||
<div className="card-body p-0"></div>
|
<div className='card-body p-0'/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'user/profile',
|
path: 'user/profile',
|
||||||
module: [() => import('../views/user/profile/index')],
|
module: [async () => import('../views/user/profile/index')],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'user/oauth/manage',
|
path: 'user/oauth/manage',
|
||||||
react: () => import('../views/user/OAuth'),
|
react: async () => import('../views/user/OAuth'),
|
||||||
el: '.content > .container-fluid',
|
el: '.content > .container-fluid',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
module: [() => import('../views/admin/Dashboard')],
|
module: [async () => import('../views/admin/Dashboard')],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/users',
|
path: 'admin/users',
|
||||||
react: () => import('../views/admin/UsersManagement'),
|
react: async () => import('../views/admin/UsersManagement'),
|
||||||
el: '.content > .container-fluid',
|
el: '.content > .container-fluid',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/players',
|
path: 'admin/players',
|
||||||
react: () => import('../views/admin/PlayersManagement'),
|
react: async () => import('../views/admin/PlayersManagement'),
|
||||||
el: '.content > .container-fluid',
|
el: '.content > .container-fluid',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/reports',
|
path: 'admin/reports',
|
||||||
react: () => import('../views/admin/ReportsManagement'),
|
react: async () => import('../views/admin/ReportsManagement'),
|
||||||
el: '.content > .container-fluid',
|
el: '.content > .container-fluid',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/customize',
|
path: 'admin/customize',
|
||||||
module: [() => import('../views/admin/Customization')],
|
module: [async () => import('../views/admin/Customization')],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/i18n',
|
path: 'admin/i18n',
|
||||||
react: () => import('../views/admin/Translations'),
|
react: async () => import('../views/admin/Translations'),
|
||||||
el: '#table',
|
el: '#table',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/plugins/manage',
|
path: 'admin/plugins/manage',
|
||||||
react: () => import('../views/admin/PluginsManagement'),
|
react: async () => import('../views/admin/PluginsManagement'),
|
||||||
el: '.content > .container-fluid',
|
el: '.content > .container-fluid',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/plugins/market',
|
path: 'admin/plugins/market',
|
||||||
react: () => import('../views/admin/PluginsMarket'),
|
react: async () => import('../views/admin/PluginsMarket'),
|
||||||
el: '.content > .container-fluid',
|
el: '.content > .container-fluid',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/update',
|
path: 'admin/update',
|
||||||
module: [() => import('../views/admin/Update')],
|
module: [async () => import('../views/admin/Update')],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'auth/login',
|
path: 'auth/login',
|
||||||
react: () => import('../views/auth/Login'),
|
react: async () => import('../views/auth/Login'),
|
||||||
el: 'main',
|
el: 'main',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'auth/register',
|
path: 'auth/register',
|
||||||
react: () => import('../views/auth/Registration'),
|
react: async () => import('../views/auth/Registration'),
|
||||||
el: 'main',
|
el: 'main',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'auth/forgot',
|
path: 'auth/forgot',
|
||||||
react: () => import('../views/auth/Forgot'),
|
react: async () => import('../views/auth/Forgot'),
|
||||||
el: 'main',
|
el: 'main',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'auth/reset/(\\d+)',
|
path: 'auth/reset/(\\d+)',
|
||||||
react: () => import('../views/auth/Reset'),
|
react: async () => import('../views/auth/Reset'),
|
||||||
el: 'main',
|
el: 'main',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'skinlib',
|
path: 'skinlib',
|
||||||
react: () => import('../views/skinlib/SkinLibrary'),
|
react: async () => import('../views/skinlib/SkinLibrary'),
|
||||||
el: '.content-wrapper',
|
el: '.content-wrapper',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'skinlib/show/(\\d+)',
|
path: 'skinlib/show/(\\d+)',
|
||||||
react: () => import('../views/skinlib/Show'),
|
react: async () => import('../views/skinlib/Show'),
|
||||||
el: '#side',
|
el: '#side',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'skinlib/upload',
|
path: 'skinlib/upload',
|
||||||
react: () => import('../views/skinlib/Upload'),
|
react: async () => import('../views/skinlib/Upload'),
|
||||||
el: '#file-input',
|
el: '#file-input',
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,49 @@
|
||||||
import { loadSkinToCanvas } from 'skinview-utils'
|
import {loadSkinToCanvas} from 'skinview-utils';
|
||||||
|
|
||||||
/* istanbul ignore next */
|
|
||||||
function checkPixel(
|
function checkPixel(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
const imageData = context.getImageData(x, y, 1, 1)
|
const imageData = context.getImageData(x, y, 1, 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
imageData.data[0] === 0 &&
|
imageData.data[0] === 0
|
||||||
imageData.data[1] === 0 &&
|
&& imageData.data[1] === 0
|
||||||
imageData.data[2] === 0
|
&& imageData.data[2] === 0
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* istanbul ignore next */
|
export async function isAlex(texture: string): Promise<boolean> {
|
||||||
export function isAlex(texture: string): Promise<boolean> {
|
return new Promise(resolve => {
|
||||||
return new Promise((resolve) => {
|
const image = new Image();
|
||||||
const image = new Image()
|
image.src = texture;
|
||||||
image.src = texture
|
image.addEventListener('load', () => {
|
||||||
image.onload = () => {
|
if (image.width !== image.height) {
|
||||||
if (image.width !== image.height) {
|
resolve(false);
|
||||||
resolve(false)
|
return;
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas');
|
||||||
loadSkinToCanvas(canvas, image)
|
loadSkinToCanvas(canvas, image);
|
||||||
|
|
||||||
const ratio = canvas.width / 64
|
const ratio = canvas.width / 64;
|
||||||
const context = canvas.getContext('2d')
|
const context = canvas.getContext('2d');
|
||||||
if (!context) {
|
if (!context) {
|
||||||
resolve(false)
|
resolve(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let x = 46 * ratio; x < 48 * ratio; x += 1) {
|
for (let x = 46 * ratio; x < 48 * ratio; x += 1) {
|
||||||
for (let y = 52 * ratio; y < 64 * ratio; y += 1) {
|
for (let y = 52 * ratio; y < 64 * ratio; y += 1) {
|
||||||
if (!checkPixel(context, x, y)) {
|
if (!checkPixel(context, x, y)) {
|
||||||
resolve(false)
|
resolve(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(true)
|
resolve(true);
|
||||||
}
|
});
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,101 @@
|
||||||
import React, { useState, useEffect } from 'react'
|
import {nanoid} from 'nanoid';
|
||||||
import ReactDOM from 'react-dom'
|
import React, {useEffect, useState} from 'react';
|
||||||
import { nanoid } from 'nanoid'
|
import {createRoot, type Root} from 'react-dom/client';
|
||||||
import * as emitter from './event'
|
import ToastBox, {type ToastType} from '../components/Toast';
|
||||||
import ToastBox, { ToastType } from '../components/Toast'
|
import * as emitter from './event';
|
||||||
|
|
||||||
type QueueElement = { id: string; type: ToastType; message: string }
|
type QueueElement = {id: string; type: ToastType; message: string};
|
||||||
type ToastQueue = QueueElement[]
|
type ToastQueue = QueueElement[];
|
||||||
|
|
||||||
const TOAST_EVENT = Symbol('toast')
|
const ToastEvent = Symbol('toast');
|
||||||
const CLEAR_EVENT = Symbol('clear')
|
const ClearEvent = Symbol('clear');
|
||||||
|
|
||||||
export const ToastContainer: React.FC = () => {
|
export function ToastContainer() {
|
||||||
const [queue, setQueue] = useState<ToastQueue>([])
|
const [queue, setQueue] = useState<ToastQueue>([]);
|
||||||
|
|
||||||
const handleClose = (id: string) => {
|
const handleClose = (id: string) => {
|
||||||
setQueue((queue) => queue.filter((el) => el.id !== id))
|
setQueue(queue => queue.filter(element => element.id !== id));
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off1 = emitter.on(TOAST_EVENT, (toast: QueueElement) => {
|
const off1 = emitter.on(ToastEvent, (toast: QueueElement) => {
|
||||||
setQueue((queue) => {
|
setQueue(queue => {
|
||||||
queue.push(toast)
|
queue.push(toast);
|
||||||
return queue.slice()
|
return [...queue];
|
||||||
})
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
// Effect dependency is empty
|
||||||
handleClose(toast.id)
|
// eslint-disable-next-line react-web-api/no-leaked-timeout
|
||||||
}, 3100)
|
setTimeout(() => {
|
||||||
})
|
handleClose(toast.id);
|
||||||
const off2 = emitter.on(CLEAR_EVENT, () => setQueue([]))
|
}, 3100);
|
||||||
|
});
|
||||||
|
const off2 = emitter.on(ClearEvent, () => {
|
||||||
|
setQueue([]);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
off1()
|
off1();
|
||||||
off2()
|
off2();
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{queue.map((el, i) => (
|
{queue.map((element, i) => (
|
||||||
<ToastBox
|
<ToastBox
|
||||||
key={el.id}
|
key={element.id}
|
||||||
type={el.type}
|
type={element.type}
|
||||||
distance={50 + i * 70}
|
distance={50 + (i * 70)}
|
||||||
onClose={() => handleClose(el.id)}
|
onClose={() => {
|
||||||
>
|
handleClose(element.id);
|
||||||
{el.message}
|
}}
|
||||||
</ToastBox>
|
>
|
||||||
))}
|
{element.message}
|
||||||
</>
|
</ToastBox>
|
||||||
)
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Toast {
|
export class Toast {
|
||||||
private container: HTMLDivElement
|
private readonly container: HTMLDivElement;
|
||||||
|
private readonly root: Root;
|
||||||
|
|
||||||
constructor(render?: (element: JSX.Element) => void) {
|
constructor(render?: (element: React.JSX.Element) => void) {
|
||||||
this.container = document.createElement('div')
|
this.container = document.createElement('div');
|
||||||
document.body.appendChild(this.container)
|
document.body.append(this.container);
|
||||||
|
this.root = createRoot(this.container);
|
||||||
|
|
||||||
if (render) {
|
if (render) {
|
||||||
render(<ToastContainer />)
|
render(<ToastContainer/>);
|
||||||
} else {
|
} else {
|
||||||
ReactDOM.render(<ToastContainer />, this.container)
|
this.root.render(<ToastContainer/>);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
success(message: string) {
|
success(message: string) {
|
||||||
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'success', message })
|
emitter.emit(ToastEvent, {id: nanoid(4), type: 'success', message});
|
||||||
}
|
}
|
||||||
|
|
||||||
info(message: string) {
|
info(message: string) {
|
||||||
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'info', message })
|
emitter.emit(ToastEvent, {id: nanoid(4), type: 'info', message});
|
||||||
}
|
}
|
||||||
|
|
||||||
warning(message: string) {
|
warning(message: string) {
|
||||||
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'warning', message })
|
emitter.emit(ToastEvent, {id: nanoid(4), type: 'warning', message});
|
||||||
}
|
}
|
||||||
|
|
||||||
error(message: string) {
|
error(message: string) {
|
||||||
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'error', message })
|
emitter.emit(ToastEvent, {id: nanoid(4), type: 'error', message});
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
emitter.emit(CLEAR_EVENT)
|
emitter.emit(ClearEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
ReactDOM.unmountComponentAtNode(this.container)
|
this.root.unmount();
|
||||||
this.container.remove()
|
this.container.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,61 @@
|
||||||
export type User = {
|
export type User = {
|
||||||
uid: number
|
uid: number;
|
||||||
email: string
|
email: string;
|
||||||
nickname: string
|
nickname: string;
|
||||||
locale: string | null
|
locale: string | undefined;
|
||||||
score: number
|
score: number;
|
||||||
avatar: number
|
avatar: number;
|
||||||
permission: UserPermission
|
permission: UserPermission;
|
||||||
ip: string
|
ip: string;
|
||||||
is_dark_mode: boolean
|
is_dark_mode: boolean;
|
||||||
last_sign_at: string
|
last_sign_at: string;
|
||||||
register_at: string
|
register_at: string;
|
||||||
verified: boolean
|
verified: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const enum UserPermission {
|
export const enum UserPermission {
|
||||||
Banned = -1,
|
Banned = -1,
|
||||||
Normal = 0,
|
Normal = 0,
|
||||||
Admin = 1,
|
Admin = 1,
|
||||||
SuperAdmin = 2,
|
SuperAdmin = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Player = {
|
export type Player = {
|
||||||
pid: number
|
pid: number;
|
||||||
name: string
|
name: string;
|
||||||
uid: number
|
uid: number;
|
||||||
tid_skin: number
|
tid_skin: number;
|
||||||
tid_cape: number
|
tid_cape: number;
|
||||||
last_modified: string
|
last_modified: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type Texture = {
|
export type Texture = {
|
||||||
tid: number
|
tid: number;
|
||||||
name: string
|
name: string;
|
||||||
type: TextureType
|
type: TextureType;
|
||||||
hash: string
|
hash: string;
|
||||||
size: number
|
size: number;
|
||||||
uploader: number
|
uploader: number;
|
||||||
public: boolean
|
public: boolean;
|
||||||
upload_at: string
|
upload_at: string;
|
||||||
likes: number
|
likes: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const enum TextureType {
|
export const enum TextureType {
|
||||||
Steve = 'steve',
|
Steve = 'steve',
|
||||||
Alex = 'alex',
|
Alex = 'alex',
|
||||||
Cape = 'cape',
|
Cape = 'cape',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClosetItem = Texture & {
|
export type ClosetItem = Texture & {
|
||||||
pivot: { user_uid: number; texture_tid: number; item_name: string }
|
pivot: {user_uid: number; texture_tid: number; item_name: string};
|
||||||
}
|
};
|
||||||
|
|
||||||
export type Paginator<T> = {
|
export type Paginator<T> = {
|
||||||
data: T[]
|
data: T[];
|
||||||
current_page: number
|
current_page: number;
|
||||||
last_page: number
|
last_page: number;
|
||||||
from: number
|
from: number;
|
||||||
to: number
|
to: number;
|
||||||
total: number
|
total: number;
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,68 @@
|
||||||
export default {
|
export default {
|
||||||
admin: {
|
admin: {
|
||||||
players: {
|
players: {
|
||||||
delete: (player: number) => `/admin/players/${player}`,
|
delete: (player: number) => `/admin/players/${player}`,
|
||||||
list: () => '/admin/players/list' as const,
|
list: () => '/admin/players/list' as const,
|
||||||
name: (player: number) => `/admin/players/${player}/name`,
|
name: (player: number) => `/admin/players/${player}/name`,
|
||||||
owner: (player: number) => `/admin/players/${player}/owner`,
|
owner: (player: number) => `/admin/players/${player}/owner`,
|
||||||
texture: (player: number) => `/admin/players/${player}/textures`,
|
texture: (player: number) => `/admin/players/${player}/textures`,
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
delete: (user: number) => `/admin/users/${user}`,
|
delete: (user: number) => `/admin/users/${user}`,
|
||||||
email: (user: number) => `/admin/users/${user}/email`,
|
email: (user: number) => `/admin/users/${user}/email`,
|
||||||
list: () => '/admin/users/list' as const,
|
list: () => '/admin/users/list' as const,
|
||||||
nickname: (user: number) => `/admin/users/${user}/nickname`,
|
nickname: (user: number) => `/admin/users/${user}/nickname`,
|
||||||
password: (user: number) => `/admin/users/${user}/password`,
|
password: (user: number) => `/admin/users/${user}/password`,
|
||||||
permission: (user: number) => `/admin/users/${user}/permission`,
|
permission: (user: number) => `/admin/users/${user}/permission`,
|
||||||
score: (user: number) => `/admin/users/${user}/score`,
|
score: (user: number) => `/admin/users/${user}/score`,
|
||||||
verification: (user: number) => `/admin/users/${user}/verification`,
|
verification: (user: number) => `/admin/users/${user}/verification`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
bind: () => '/auth/bind' as const,
|
bind: () => '/auth/bind' as const,
|
||||||
forgot: () => '/auth/forgot' as const,
|
forgot: () => '/auth/forgot' as const,
|
||||||
login: () => '/auth/login' as const,
|
login: () => '/auth/login' as const,
|
||||||
logout: () => '/auth/logout' as const,
|
logout: () => '/auth/logout' as const,
|
||||||
register: () => '/auth/register' as const,
|
register: () => '/auth/register' as const,
|
||||||
reset: (uid: number) => `/auth/reset/${uid}`,
|
reset: (uid: number) => `/auth/reset/${uid}`,
|
||||||
verify: (uid: number) => `/auth/verify/${uid}`,
|
verify: (uid: number) => `/auth/verify/${uid}`,
|
||||||
},
|
},
|
||||||
skinlib: {
|
skinlib: {
|
||||||
home: () => '/skinlib' as const,
|
home: () => '/skinlib' as const,
|
||||||
info: (texture: number) => `/skinlib/info/${texture}`,
|
info: (texture: number) => `/skinlib/info/${texture}`,
|
||||||
list: () => '/skinlib/list' as const,
|
list: () => '/skinlib/list' as const,
|
||||||
show: (tid: number) => `/skinlib/show/${tid}`,
|
show: (tid: number) => `/skinlib/show/${tid}`,
|
||||||
},
|
},
|
||||||
texture: {
|
texture: {
|
||||||
delete: (texture: number) => `/texture/${texture}`,
|
delete: (texture: number) => `/texture/${texture}`,
|
||||||
info: (texture: number) => `/texture/${texture}`,
|
info: (texture: number) => `/texture/${texture}`,
|
||||||
name: (texture: number) => `/texture/${texture}/name`,
|
name: (texture: number) => `/texture/${texture}/name`,
|
||||||
privacy: (texture: number) => `/texture/${texture}/privacy`,
|
privacy: (texture: number) => `/texture/${texture}/privacy`,
|
||||||
type: (texture: number) => `/texture/${texture}/type`,
|
type: (texture: number) => `/texture/${texture}/type`,
|
||||||
upload: () => '/texture' as const,
|
upload: () => '/texture' as const,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
closet: {
|
closet: {
|
||||||
add: () => '/user/closet' as const,
|
add: () => '/user/closet' as const,
|
||||||
ids: () => '/user/closet/ids' as const,
|
ids: () => '/user/closet/ids' as const,
|
||||||
list: () => '/user/closet/list' as const,
|
list: () => '/user/closet/list' as const,
|
||||||
page: () => '/user/closet' as const,
|
page: () => '/user/closet' as const,
|
||||||
remove: (tid: number) => `/user/closet/${tid}`,
|
remove: (tid: number) => `/user/closet/${tid}`,
|
||||||
rename: (tid: number) => `/user/closet/${tid}`,
|
rename: (tid: number) => `/user/closet/${tid}`,
|
||||||
},
|
},
|
||||||
home: () => '/user' as const,
|
home: () => '/user' as const,
|
||||||
notification: (id: number) => `/user/notifications/${id}`,
|
notification: (id: number) => `/user/notifications/${id}`,
|
||||||
player: {
|
player: {
|
||||||
add: () => '/user/player' as const,
|
add: () => '/user/player' as const,
|
||||||
clear: (player: number) => `/user/player/${player}/textures`,
|
clear: (player: number) => `/user/player/${player}/textures`,
|
||||||
delete: (player: number) => `/user/player/${player}`,
|
delete: (player: number) => `/user/player/${player}`,
|
||||||
list: () => '/user/player/list' as const,
|
list: () => '/user/player/list' as const,
|
||||||
page: () => '/user/player' as const,
|
page: () => '/user/player' as const,
|
||||||
rename: (player: number) => `/user/player/${player}/name`,
|
rename: (player: number) => `/user/player/${player}/name`,
|
||||||
set: (player: number) => `/user/player/${player}/textures`,
|
set: (player: number) => `/user/player/${player}/textures`,
|
||||||
},
|
},
|
||||||
profile: { avatar: () => '/user/profile/avatar' as const },
|
profile: {avatar: () => '/user/profile/avatar' as const},
|
||||||
score: () => '/user/score-info' as const,
|
score: () => '/user/score-info' as const,
|
||||||
sign: () => '/user/sign' as const,
|
sign: () => '/user/sign' as const,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
|
||||||
56
resources/assets/src/shims.d.ts
vendored
56
resources/assets/src/shims.d.ts
vendored
|
|
@ -1,35 +1,33 @@
|
||||||
import JQuery from 'jquery'
|
import type {ModalOptions, ModalResult} from './components/Modal';
|
||||||
import { ModalOptions, ModalResult } from './components/Modal'
|
import type {Toast} from './scripts/toast';
|
||||||
import { Toast } from './scripts/toast'
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-redeclare
|
let blessing: {
|
||||||
let blessing: {
|
base_url: string;
|
||||||
base_url: string
|
debug: boolean;
|
||||||
debug: boolean
|
env: string;
|
||||||
env: string
|
locale: string;
|
||||||
locale: string
|
site_name: string;
|
||||||
site_name: string
|
version: string;
|
||||||
version: string
|
route: string;
|
||||||
route: string
|
extra: Record<string, unknown>;
|
||||||
extra: any
|
i18n: Record<string, unknown>;
|
||||||
i18n: object
|
|
||||||
|
|
||||||
fetch: {
|
fetch: {
|
||||||
get(url: string, params?: object): Promise<object>
|
get: (url: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
post(url: string, data?: object): Promise<object>
|
post: (url: string, data?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
put(url: string, data?: object): Promise<object>
|
put: (url: string, data?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
del(url: string, data?: object): Promise<object>
|
del: (url: string, data?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
}
|
};
|
||||||
|
|
||||||
event: {
|
event: {
|
||||||
on(eventName: string, listener: Function): void
|
on: (eventName: string, listener: (...args: any[]) => void) => void;
|
||||||
emit(eventName: string, payload: object): void
|
emit: (eventName: string, payload: Record<string, unknown>) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
notify: {
|
notify: {
|
||||||
showModal(options?: ModalOptions): Promise<ModalResult>
|
showModal: (options?: ModalOptions) => Promise<ModalResult>;
|
||||||
toast: Toast
|
toast: Toast;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
@import 'spectre.css/src/spectre.scss';
|
||||||
|
@import '@/fonts/minecraft.css';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
height: 97vh;
|
height: 97vh;
|
||||||
}
|
}
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
export const enum Breakpoint {
|
export const enum Breakpoint {
|
||||||
xs = 0,
|
xs = 0,
|
||||||
sm = 576,
|
sm = 576,
|
||||||
md = 768,
|
md = 768,
|
||||||
lg = 992,
|
lg = 992,
|
||||||
xl = 1200,
|
xl = 1200,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lessThan(breakpoint: Breakpoint): string {
|
export function lessThan(breakpoint: Breakpoint): string {
|
||||||
return `@media (max-width: ${breakpoint}px)`
|
return `@media (max-width: ${breakpoint}px)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function between(down: Breakpoint, up: Breakpoint): string {
|
export function between(down: Breakpoint, up: Breakpoint): string {
|
||||||
return `@media (min-width: ${down}px) and (max-width: ${up}px)`
|
return `@media (min-width: ${down}px) and (max-width: ${up}px)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function greaterThan(breakpoint: Breakpoint): string {
|
export function greaterThan(breakpoint: Breakpoint): string {
|
||||||
return `@media (min-width: ${breakpoint}px)`
|
return `@media (min-width: ${breakpoint}px)`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { css } from '@emotion/react'
|
import {css} from '@emotion/react';
|
||||||
|
|
||||||
export const pointerCursor = css`
|
export const pointerCursor = css`
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
`
|
`;
|
||||||
|
|
||||||
export const center = css`
|
export const center = css`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
`
|
`;
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,79 @@
|
||||||
/* eslint-disable object-curly-newline */
|
|
||||||
import { fromEvent, merge, of, partition } from 'rxjs'
|
import {
|
||||||
import { filter, map, pairwise } from 'rxjs/operators'
|
fromEvent,
|
||||||
|
merge,
|
||||||
|
of,
|
||||||
|
partition,
|
||||||
|
} from 'rxjs';
|
||||||
|
import {filter, map, pairwise} from 'rxjs/operators';
|
||||||
|
|
||||||
export function registerNavbarPicker(
|
export function registerNavbarPicker(
|
||||||
navbar: HTMLElement,
|
navbar: HTMLElement,
|
||||||
picker: HTMLDivElement,
|
picker: HTMLDivElement,
|
||||||
init: string,
|
init: string,
|
||||||
): void {
|
): void {
|
||||||
const color$ = fromEvent(picker, 'click').pipe(
|
const color$ = fromEvent(picker, 'click').pipe(
|
||||||
map((event) => event.target as HTMLElement),
|
map(event => event.target as HTMLElement),
|
||||||
filter(
|
filter((element): element is HTMLInputElement => element.tagName === 'INPUT'),
|
||||||
(element): element is HTMLInputElement => element.tagName === 'INPUT',
|
map(element => element.value),
|
||||||
),
|
);
|
||||||
map((element) => element.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
merge(of(init), color$)
|
merge(of(init), color$)
|
||||||
.pipe(pairwise())
|
.pipe(pairwise())
|
||||||
.subscribe(([previous, current]) => {
|
.subscribe(([previous, current]) => {
|
||||||
navbar.classList.replace(`navbar-${previous}`, `navbar-${current}`)
|
navbar.classList.replace(`navbar-${previous}`, `navbar-${current}`);
|
||||||
})
|
});
|
||||||
|
|
||||||
const [light$, dark$] = partition(color$, (color) =>
|
const [light$, dark$] = partition(color$, color =>
|
||||||
['light', 'warning', 'white', 'orange', 'lime'].includes(color),
|
['light', 'warning', 'white', 'orange', 'lime'].includes(color));
|
||||||
)
|
light$.subscribe(() => {
|
||||||
light$.subscribe(() => {
|
// DO NOT use `classList.replace`.
|
||||||
// DO NOT use `classList.replace`.
|
navbar.classList.remove('navbar-dark');
|
||||||
navbar.classList.remove('navbar-dark')
|
navbar.classList.add('navbar-light');
|
||||||
navbar.classList.add('navbar-light')
|
});
|
||||||
})
|
dark$.subscribe(() => {
|
||||||
dark$.subscribe(() => {
|
// DO NOT use `classList.replace`.
|
||||||
// DO NOT use `classList.replace`.
|
navbar.classList.remove('navbar-light');
|
||||||
navbar.classList.remove('navbar-light')
|
navbar.classList.add('navbar-dark');
|
||||||
navbar.classList.add('navbar-dark')
|
});
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const navbar = document.querySelector<HTMLElement>('.wrapper > nav')
|
const navbar = document.querySelector<HTMLElement>('.wrapper > nav');
|
||||||
const picker = document.querySelector<HTMLDivElement>('#navbar-color-picker')
|
const picker = document.querySelector<HTMLDivElement>('#navbar-color-picker');
|
||||||
/* istanbul ignore next */
|
|
||||||
if (navbar && picker) {
|
if (navbar && picker) {
|
||||||
registerNavbarPicker(navbar, picker, blessing.extra.navbar || 'white')
|
registerNavbarPicker(navbar, picker, blessing.extra.navbar as string || 'white');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerSidebarPicker(
|
export function registerSidebarPicker(
|
||||||
sidebar: HTMLElement,
|
sidebar: HTMLElement,
|
||||||
{ dark, light }: { dark: HTMLDivElement; light: HTMLDivElement },
|
{dark, light}: {dark: HTMLDivElement; light: HTMLDivElement},
|
||||||
init: string,
|
init: string,
|
||||||
): void {
|
): void {
|
||||||
const color$ = merge(
|
const color$ = merge(
|
||||||
fromEvent(dark, 'click'),
|
fromEvent(dark, 'click'),
|
||||||
fromEvent(light, 'click'),
|
fromEvent(light, 'click'),
|
||||||
).pipe(
|
).pipe(
|
||||||
map((event) => event.target as HTMLElement),
|
map(event => event.target as HTMLElement),
|
||||||
filter(
|
filter((element): element is HTMLInputElement => element.tagName === 'INPUT'),
|
||||||
(element): element is HTMLInputElement => element.tagName === 'INPUT',
|
map(element => element.value),
|
||||||
),
|
);
|
||||||
map((element) => element.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
merge(of(init), color$)
|
merge(of(init), color$)
|
||||||
.pipe(pairwise())
|
.pipe(pairwise())
|
||||||
.subscribe(([previous, current]) => {
|
.subscribe(([previous, current]) => {
|
||||||
sidebar.classList.replace(`sidebar-${previous}`, `sidebar-${current}`)
|
sidebar.classList.replace(`sidebar-${previous}`, `sidebar-${current}`);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebar = document.querySelector<HTMLElement>('.main-sidebar')
|
const sidebar = document.querySelector<HTMLElement>('.main-sidebar');
|
||||||
const darkPicker = document.querySelector<HTMLDivElement>(
|
const darkPicker = document.querySelector<HTMLDivElement>('#sidebar-dark-picker');
|
||||||
'#sidebar-dark-picker',
|
const lightPicker = document.querySelector<HTMLDivElement>('#sidebar-light-picker');
|
||||||
)
|
|
||||||
const lightPicker = document.querySelector<HTMLDivElement>(
|
|
||||||
'#sidebar-light-picker',
|
|
||||||
)
|
|
||||||
/* istanbul ignore next */
|
|
||||||
if (sidebar && darkPicker && lightPicker) {
|
if (sidebar && darkPicker && lightPicker) {
|
||||||
registerSidebarPicker(
|
registerSidebarPicker(
|
||||||
sidebar,
|
sidebar,
|
||||||
{ dark: darkPicker, light: lightPicker },
|
{dark: darkPicker, light: lightPicker},
|
||||||
blessing.extra.sidebar || 'dark-primary',
|
blessing.extra.sidebar as string || 'dark-primary',
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,120 +1,116 @@
|
||||||
import * as echarts from 'echarts/core'
|
import {LineChart} from 'echarts/charts';
|
||||||
import { SVGRenderer } from 'echarts/renderers'
|
|
||||||
import { LineChart } from 'echarts/charts'
|
|
||||||
import {
|
import {
|
||||||
DataZoomComponent,
|
DataZoomComponent,
|
||||||
GridComponent,
|
GridComponent,
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
} from 'echarts/components'
|
} from 'echarts/components';
|
||||||
import { get } from '../../scripts/net'
|
import * as echarts from 'echarts/core';
|
||||||
|
import {SVGRenderer} from 'echarts/renderers';
|
||||||
|
import {get} from '../../scripts/net';
|
||||||
|
|
||||||
interface ChartData {
|
type ChartData = {
|
||||||
labels: string[]
|
labels: string[];
|
||||||
xAxis: string[]
|
xAxis: string[];
|
||||||
data: number[][]
|
data: number[][];
|
||||||
}
|
};
|
||||||
|
|
||||||
interface SingleChartData {
|
type SingleChartData = {
|
||||||
label: string
|
label: string;
|
||||||
xAxis: string[]
|
xAxis: string[];
|
||||||
data: number[]
|
data: number[];
|
||||||
}
|
};
|
||||||
|
|
||||||
echarts.use([
|
echarts.use([
|
||||||
SVGRenderer,
|
SVGRenderer,
|
||||||
LineChart,
|
LineChart,
|
||||||
DataZoomComponent,
|
DataZoomComponent,
|
||||||
GridComponent,
|
GridComponent,
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
])
|
]);
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const elUsersRegistration = document.querySelector<HTMLDivElement>(
|
const elementUsersRegistration = document.querySelector<HTMLDivElement>('#chart-users-registration');
|
||||||
'#chart-users-registration',
|
const elementTexturesUpload = document.querySelector<HTMLDivElement>('#chart-textures-upload');
|
||||||
)
|
if (!elementUsersRegistration || !elementTexturesUpload) {
|
||||||
const elTexturesUpload = document.querySelector<HTMLDivElement>(
|
return;
|
||||||
'#chart-textures-upload',
|
}
|
||||||
)
|
|
||||||
if (!elUsersRegistration || !elTexturesUpload) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDarkMode = document.body.classList.contains('dark-mode')
|
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||||
const textColor = isDarkMode ? '#fff' : '#000'
|
const textColor = isDarkMode ? '#fff' : '#000';
|
||||||
|
|
||||||
const chartData: ChartData = await get('/admin/chart')
|
const chartData: ChartData = await get('/admin/chart');
|
||||||
createLineChart(
|
createLineChart(
|
||||||
elUsersRegistration,
|
elementUsersRegistration,
|
||||||
isDarkMode ? '#3498db' : '#17a2b8',
|
isDarkMode ? '#3498db' : '#17a2b8',
|
||||||
textColor,
|
textColor,
|
||||||
{
|
{
|
||||||
label: chartData.labels[0]!,
|
label: chartData.labels[0],
|
||||||
xAxis: chartData.xAxis,
|
xAxis: chartData.xAxis,
|
||||||
data: chartData.data[0]!,
|
data: chartData.data[0],
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
createLineChart(elTexturesUpload, '#6f42c1', textColor, {
|
createLineChart(elementTexturesUpload, '#6f42c1', textColor, {
|
||||||
label: chartData.labels[1]!,
|
label: chartData.labels[1],
|
||||||
xAxis: chartData.xAxis,
|
xAxis: chartData.xAxis,
|
||||||
data: chartData.data[1]!,
|
data: chartData.data[1],
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createLineChart(
|
function createLineChart(
|
||||||
el: HTMLDivElement,
|
element: HTMLDivElement,
|
||||||
color: string,
|
color: string,
|
||||||
textColor: string,
|
textColor: string,
|
||||||
data: SingleChartData,
|
data: SingleChartData,
|
||||||
) {
|
) {
|
||||||
const chart = echarts.init(el)
|
const chart = echarts.init(element);
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
title: {
|
title: {
|
||||||
text: data.label,
|
text: data.label,
|
||||||
textStyle: {
|
textStyle: {
|
||||||
color: textColor,
|
color: textColor,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
textStyle: {
|
textStyle: {
|
||||||
color: textColor,
|
color: textColor,
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
},
|
},
|
||||||
dataZoom: [
|
dataZoom: [
|
||||||
{ type: 'inside', start: 75 },
|
{type: 'inside', start: 75},
|
||||||
{ type: 'slider', start: 75 },
|
{type: 'slider', start: 75},
|
||||||
],
|
],
|
||||||
xAxis: [
|
xAxis: [
|
||||||
{
|
{
|
||||||
type: 'category',
|
type: 'category',
|
||||||
boundaryGap: false,
|
boundaryGap: false,
|
||||||
data: data.xAxis,
|
data: data.xAxis,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
yAxis: [
|
yAxis: [
|
||||||
{
|
{
|
||||||
type: 'value',
|
type: 'value',
|
||||||
minInterval: 1,
|
minInterval: 1,
|
||||||
boundaryGap: false,
|
boundaryGap: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: data.label,
|
name: data.label,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color,
|
color,
|
||||||
},
|
},
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
color,
|
color,
|
||||||
},
|
},
|
||||||
data: data.data,
|
data: data.data,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
void main();
|
||||||
|
|
|
||||||
|
|
@ -1,163 +1,170 @@
|
||||||
import React from 'react'
|
|
||||||
import { t } from '@/scripts/i18n'
|
|
||||||
import { showModal } from '@/scripts/notify'
|
|
||||||
import type { Player } from '@/scripts/types'
|
|
||||||
import { Box } from './styles'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
interface Props {
|
import type {Player} from '@/scripts/types';
|
||||||
player: Player
|
import {t} from '@/scripts/i18n';
|
||||||
onUpdateName(): void
|
import {showModal} from '@/scripts/notify';
|
||||||
onUpdateOwner(): void
|
import clsx from 'clsx';
|
||||||
onUpdateTexture(): void
|
import {Box} from './styles';
|
||||||
onDelete(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const Card: React.FC<Props> = (props) => {
|
type Props = {
|
||||||
const { player } = props
|
readonly player: Player;
|
||||||
|
onUpdateName: () => void;
|
||||||
|
onUpdateOwner: () => void;
|
||||||
|
onUpdateTexture: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
const handlePreviewTextures = () => {
|
const Card: React.FC<Props> = props => {
|
||||||
const skinPreview = `${blessing.base_url}/preview/${player.tid_skin}`
|
const {player} = props;
|
||||||
const skinPreviewPNG = `${skinPreview}?png`
|
|
||||||
const capePreview = `${blessing.base_url}/preview/${player.tid_cape}`
|
|
||||||
const capePreviewPNG = `${capePreview}?png`
|
|
||||||
|
|
||||||
showModal({
|
const handlePreviewTextures = () => {
|
||||||
mode: 'alert',
|
const skinPreview = `${blessing.base_url}/preview/${player.tid_skin}`;
|
||||||
title: t('general.player.previews'),
|
const skinPreviewPNG = `${skinPreview}?png`;
|
||||||
children: (
|
const capePreview = `${blessing.base_url}/preview/${player.tid_cape}`;
|
||||||
<div className="row">
|
const capePreviewPNG = `${capePreview}?png`;
|
||||||
<div className="col-6 d-flex justify-content-center">
|
|
||||||
{player.tid_skin > 0 && (
|
|
||||||
<a
|
|
||||||
href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<picture>
|
|
||||||
<source srcSet={skinPreview} type="image/webp" />
|
|
||||||
<img
|
|
||||||
src={skinPreviewPNG}
|
|
||||||
alt={`${player.name} - ${t('general.skin')}`}
|
|
||||||
width="128"
|
|
||||||
/>
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="col-6 d-flex justify-content-center">
|
|
||||||
{player.tid_cape > 0 && (
|
|
||||||
<a
|
|
||||||
href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<picture>
|
|
||||||
<source srcSet={capePreview} type="image/webp" />
|
|
||||||
<img
|
|
||||||
src={capePreviewPNG}
|
|
||||||
alt={`${player.name} - ${t('general.cape')}`}
|
|
||||||
width="128"
|
|
||||||
/>
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDarkMode = document.body.classList.contains('dark-mode')
|
showModal({
|
||||||
|
mode: 'alert',
|
||||||
|
title: t('general.player.previews'),
|
||||||
|
children: (
|
||||||
|
<div className='row'>
|
||||||
|
<div className='col-6 d-flex justify-content-center'>
|
||||||
|
{player.tid_skin > 0 && (
|
||||||
|
<a
|
||||||
|
href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
<picture>
|
||||||
|
<source srcSet={skinPreview} type='image/webp'/>
|
||||||
|
<img
|
||||||
|
src={skinPreviewPNG}
|
||||||
|
alt={`${player.name} - ${t('general.skin')}`}
|
||||||
|
width='128'
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='col-6 d-flex justify-content-center'>
|
||||||
|
{player.tid_cape > 0 && (
|
||||||
|
<a
|
||||||
|
href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
<picture>
|
||||||
|
<source srcSet={capePreview} type='image/webp'/>
|
||||||
|
<img
|
||||||
|
src={capePreviewPNG}
|
||||||
|
alt={`${player.name} - ${t('general.cape')}`}
|
||||||
|
width='128'
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const avatar = `${blessing.base_url}/avatar/player/${player.name}`
|
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||||
const avatarPNG = `${avatar}?png`
|
|
||||||
|
|
||||||
return (
|
const avatar = `${blessing.base_url}/avatar/player/${player.name}`;
|
||||||
<Box className={clsx('info-box', { 'bg-gray-dark': isDarkMode })}>
|
const avatarPNG = `${avatar}?png`;
|
||||||
<div className="info-box-icon">
|
|
||||||
<picture>
|
|
||||||
<source srcSet={avatar} type="image/webp" />
|
|
||||||
<img className="bs-avatar" src={avatarPNG} />
|
|
||||||
</picture>
|
|
||||||
</div>
|
|
||||||
<div className="info-box-content">
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-10">
|
|
||||||
<b>{player.name}</b>
|
|
||||||
</div>
|
|
||||||
<div className="col-2">
|
|
||||||
<div className="float-right dropdown">
|
|
||||||
<a
|
|
||||||
className="text-gray"
|
|
||||||
href="#"
|
|
||||||
data-toggle="dropdown"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<i className="fas fa-cog"></i>
|
|
||||||
</a>
|
|
||||||
<div className="dropdown-menu dropdown-menu-right">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="dropdown-item"
|
|
||||||
onClick={handlePreviewTextures}
|
|
||||||
>
|
|
||||||
<i className="fas fa-eye mr-2"></i>
|
|
||||||
{t('general.player.previews')}
|
|
||||||
</a>
|
|
||||||
<div className="dropdown-divider"></div>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="dropdown-item"
|
|
||||||
onClick={props.onUpdateName}
|
|
||||||
>
|
|
||||||
<i className="fas fa-signature mr-2"></i>
|
|
||||||
{t('admin.changePlayerName')}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="dropdown-item"
|
|
||||||
onClick={props.onUpdateOwner}
|
|
||||||
>
|
|
||||||
<i className="fas fa-user-edit mr-2"></i>
|
|
||||||
{t('admin.changeOwner')}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="dropdown-item"
|
|
||||||
onClick={props.onUpdateTexture}
|
|
||||||
>
|
|
||||||
<i className="fas fa-tshirt mr-2"></i>
|
|
||||||
{t('admin.changeTexture')}
|
|
||||||
</a>
|
|
||||||
<div className="dropdown-divider"></div>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="dropdown-item dropdown-item-danger"
|
|
||||||
onClick={props.onDelete}
|
|
||||||
>
|
|
||||||
<i className="fas fa-trash mr-2"></i>
|
|
||||||
{t('admin.deletePlayer')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<span className="mr-2">PID: {player.pid}</span>
|
|
||||||
<span>
|
|
||||||
{t('general.player.owner')}: {player.uid}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<small className="text-gray">
|
|
||||||
{`${t('general.player.last-modified')}: `}
|
|
||||||
{player.last_modified}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Card
|
return (
|
||||||
|
<Box className={clsx('info-box', {'bg-gray-dark': isDarkMode})}>
|
||||||
|
<div className='info-box-icon'>
|
||||||
|
<picture>
|
||||||
|
<source srcSet={avatar} type='image/webp'/>
|
||||||
|
<img className='bs-avatar' src={avatarPNG}/>
|
||||||
|
</picture>
|
||||||
|
</div>
|
||||||
|
<div className='info-box-content'>
|
||||||
|
<div className='row'>
|
||||||
|
<div className='col-10'>
|
||||||
|
<b>{player.name}</b>
|
||||||
|
</div>
|
||||||
|
<div className='col-2'>
|
||||||
|
<div className='float-right dropdown'>
|
||||||
|
<a
|
||||||
|
className='text-gray'
|
||||||
|
href='#'
|
||||||
|
data-toggle='dropdown'
|
||||||
|
aria-expanded='false'
|
||||||
|
>
|
||||||
|
<i className='fas fa-cog'/>
|
||||||
|
</a>
|
||||||
|
<div className='dropdown-menu dropdown-menu-right'>
|
||||||
|
<a
|
||||||
|
href='#'
|
||||||
|
className='dropdown-item'
|
||||||
|
onClick={handlePreviewTextures}
|
||||||
|
>
|
||||||
|
<i className='fas fa-eye mr-2'/>
|
||||||
|
{t('general.player.previews')}
|
||||||
|
</a>
|
||||||
|
<div className='dropdown-divider'/>
|
||||||
|
<a
|
||||||
|
href='#'
|
||||||
|
className='dropdown-item'
|
||||||
|
onClick={props.onUpdateName}
|
||||||
|
>
|
||||||
|
<i className='fas fa-signature mr-2'/>
|
||||||
|
{t('admin.changePlayerName')}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href='#'
|
||||||
|
className='dropdown-item'
|
||||||
|
onClick={props.onUpdateOwner}
|
||||||
|
>
|
||||||
|
<i className='fas fa-user-edit mr-2'/>
|
||||||
|
{t('admin.changeOwner')}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href='#'
|
||||||
|
className='dropdown-item'
|
||||||
|
onClick={props.onUpdateTexture}
|
||||||
|
>
|
||||||
|
<i className='fas fa-tshirt mr-2'/>
|
||||||
|
{t('admin.changeTexture')}
|
||||||
|
</a>
|
||||||
|
<div className='dropdown-divider'/>
|
||||||
|
<a
|
||||||
|
href='#'
|
||||||
|
className='dropdown-item dropdown-item-danger'
|
||||||
|
onClick={props.onDelete}
|
||||||
|
>
|
||||||
|
<i className='fas fa-trash mr-2'/>
|
||||||
|
{t('admin.deletePlayer')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<span className='mr-2'>
|
||||||
|
PID:
|
||||||
|
{player.pid}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('general.player.owner')}
|
||||||
|
:
|
||||||
|
{player.uid}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small className='text-gray'>
|
||||||
|
{`${t('general.player.last-modified')}: `}
|
||||||
|
{player.last_modified}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Card;
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,36 @@
|
||||||
import React from 'react'
|
import styled from '@emotion/styled';
|
||||||
import styled from '@emotion/styled'
|
import clsx from 'clsx';
|
||||||
import Skeleton from 'react-loading-skeleton'
|
import Skeleton from 'react-loading-skeleton';
|
||||||
import { Box } from './styles'
|
import {Box} from './styles';
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
const isDarkMode = document.body.classList.contains('dark-mode')
|
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||||
|
|
||||||
const ShrinkedSkeleton = styled(Skeleton)<{ width?: string }>`
|
const ShrinkedSkeleton = styled(Skeleton)<{width?: string}>`
|
||||||
width: ${(props) => props.width};
|
width: ${props => props.width};
|
||||||
`
|
`;
|
||||||
|
|
||||||
const LoadingCard: React.FC = () => (
|
export default function LoadingCard() {
|
||||||
<Box className={clsx('info-box', { 'bg-gray-dark': isDarkMode })}>
|
return (
|
||||||
<div className="info-box-icon">
|
<Box className={clsx('info-box', {'bg-gray-dark': isDarkMode})}>
|
||||||
<Skeleton circle height={50} width={50} />
|
<div className='info-box-icon'>
|
||||||
</div>
|
<Skeleton circle height={50} width={50}/>
|
||||||
<div className="info-box-content">
|
</div>
|
||||||
<div className="row">
|
<div className='info-box-content'>
|
||||||
<div className="col-10">
|
<div className='row'>
|
||||||
<ShrinkedSkeleton width="120px" />
|
<div className='col-10'>
|
||||||
</div>
|
<ShrinkedSkeleton width='120px'/>
|
||||||
<div className="col-2"></div>
|
</div>
|
||||||
</div>
|
<div className='col-2'/>
|
||||||
<div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<ShrinkedSkeleton width="150px" />
|
<div>
|
||||||
</div>
|
<ShrinkedSkeleton width='150px'/>
|
||||||
<div>
|
</div>
|
||||||
<ShrinkedSkeleton width="180px" />
|
<div>
|
||||||
</div>
|
<ShrinkedSkeleton width='180px'/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
)
|
</Box>
|
||||||
|
);
|
||||||
export default LoadingCard
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
import React from 'react'
|
import styled from '@emotion/styled';
|
||||||
import styled from '@emotion/styled'
|
import Skeleton from 'react-loading-skeleton';
|
||||||
import Skeleton from 'react-loading-skeleton'
|
|
||||||
|
|
||||||
const ThickSkeleton = styled(Skeleton)`
|
const ThickSkeleton = styled(Skeleton)`
|
||||||
line-height: 2;
|
line-height: 2;
|
||||||
`
|
`;
|
||||||
|
|
||||||
const LoadingRow: React.FC = () => (
|
export default function LoadingRow() {
|
||||||
<tr>
|
return (
|
||||||
<td colSpan={6}>
|
<tr>
|
||||||
<ThickSkeleton />
|
<td colSpan={6}>
|
||||||
</td>
|
<ThickSkeleton/>
|
||||||
</tr>
|
</td>
|
||||||
)
|
</tr>
|
||||||
|
);
|
||||||
export default LoadingRow
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,84 +1,84 @@
|
||||||
import React, { useState } from 'react'
|
import Modal from '@/components/Modal';
|
||||||
import { t } from '@/scripts/i18n'
|
import {t} from '@/scripts/i18n';
|
||||||
import { TextureType } from '@/scripts/types'
|
import {TextureType} from '@/scripts/types';
|
||||||
import Modal from '@/components/Modal'
|
import {useState} from 'react';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
open: boolean
|
readonly open: boolean;
|
||||||
onSubmit(type: 'skin' | 'cape', tid: number): void
|
onSubmit: (type: 'skin' | 'cape', tid: number) => void;
|
||||||
onClose(): void
|
onClose: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const ModalUpdateTexture: React.FC<Props> = (props) => {
|
const ModalUpdateTexture: React.FC<Props> = props => {
|
||||||
const [type, setType] = useState<'skin' | 'cape'>('skin')
|
const [type, setType] = useState<'skin' | 'cape'>('skin');
|
||||||
const [tid, setTid] = useState('')
|
const [tid, setTid] = useState('');
|
||||||
|
|
||||||
const handleTypeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTypeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setType(event.target.value as 'skin' | 'cape')
|
setType(event.target.value as 'skin' | 'cape');
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleTidChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTidChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setTid(event.target.value)
|
setTid(event.target.value);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
props.onSubmit(type, Number.parseInt(tid))
|
props.onSubmit(type, Number.parseInt(tid));
|
||||||
setType('skin')
|
setType('skin');
|
||||||
setTid('')
|
setTid('');
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setType('skin')
|
setType('skin');
|
||||||
setTid('')
|
setTid('');
|
||||||
props.onClose()
|
props.onClose();
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
show={props.open}
|
center
|
||||||
center
|
show={props.open}
|
||||||
title={t('admin.changeTexture')}
|
title={t('admin.changeTexture')}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
>
|
>
|
||||||
<div className="form-group">
|
<div className='form-group'>
|
||||||
<label>{t('admin.textureType')}</label>
|
<label>{t('admin.textureType')}</label>
|
||||||
<div>
|
<div>
|
||||||
<label className="mr-5">
|
<label className='mr-5'>
|
||||||
<input
|
<input
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
type="radio"
|
type='radio'
|
||||||
value="skin"
|
value='skin'
|
||||||
checked={type === 'skin'}
|
checked={type === 'skin'}
|
||||||
onChange={handleTypeChange}
|
onChange={handleTypeChange}
|
||||||
/>
|
/>
|
||||||
{t('general.skin')}
|
{t('general.skin')}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
type="radio"
|
type='radio'
|
||||||
value="cape"
|
value='cape'
|
||||||
checked={type === TextureType.Cape}
|
checked={type === TextureType.Cape}
|
||||||
onChange={handleTypeChange}
|
onChange={handleTypeChange}
|
||||||
/>
|
/>
|
||||||
{t('general.cape')}
|
{t('general.cape')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className='form-group'>
|
||||||
<label htmlFor="update-texture-tid">TID</label>
|
<label htmlFor='update-texture-tid'>TID</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type='number'
|
||||||
id="update-texture-tid"
|
id='update-texture-tid'
|
||||||
className="form-control"
|
className='form-control'
|
||||||
placeholder={t('admin.pidNotice')}
|
placeholder={t('admin.pidNotice')}
|
||||||
value={tid}
|
value={tid}
|
||||||
onChange={handleTidChange}
|
onChange={handleTidChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ModalUpdateTexture
|
export default ModalUpdateTexture;
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,83 @@
|
||||||
import React from 'react'
|
|
||||||
import { t } from '@/scripts/i18n'
|
|
||||||
import type { Player } from '@/scripts/types'
|
|
||||||
import ButtonEdit from '@/components/ButtonEdit'
|
|
||||||
|
|
||||||
interface Props {
|
import type {Player} from '@/scripts/types';
|
||||||
player: Player
|
import ButtonEdit from '@/components/ButtonEdit';
|
||||||
onUpdateName(): void
|
import {t} from '@/scripts/i18n';
|
||||||
onUpdateOwner(): void
|
|
||||||
onUpdateTexture(): void
|
|
||||||
onDelete(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const Row: React.FC<Props> = (props) => {
|
type Props = {
|
||||||
const { player } = props
|
readonly player: Player;
|
||||||
|
onUpdateName: () => void;
|
||||||
|
onUpdateOwner: () => void;
|
||||||
|
onUpdateTexture: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const Row: React.FC<Props> = props => {
|
||||||
<tr>
|
const {player} = props;
|
||||||
<td>{player.pid}</td>
|
|
||||||
<td>
|
|
||||||
{player.name}
|
|
||||||
<span className="ml-1">
|
|
||||||
<ButtonEdit
|
|
||||||
title={t('admin.changePlayerName')}
|
|
||||||
onClick={props.onUpdateName}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{player.uid}
|
|
||||||
<span className="ml-1">
|
|
||||||
<ButtonEdit
|
|
||||||
title={t('admin.changeOwner')}
|
|
||||||
onClick={props.onUpdateOwner}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{player.tid_skin > 0 && (
|
|
||||||
<a
|
|
||||||
href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`}
|
|
||||||
target="_blank"
|
|
||||||
className="mr-1"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`${blessing.base_url}/preview/${player.tid_skin}`}
|
|
||||||
alt={`${player.name} - ${t('general.skin')}`}
|
|
||||||
width="64"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{player.tid_cape > 0 && (
|
|
||||||
<a
|
|
||||||
href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`${blessing.base_url}/preview/${player.tid_cape}`}
|
|
||||||
alt={`${player.name} - ${t('general.cape')}`}
|
|
||||||
width="64"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{player.last_modified}</td>
|
|
||||||
<td className="d-flex flex-wrap">
|
|
||||||
<button
|
|
||||||
className="btn btn-default mr-2"
|
|
||||||
onClick={props.onUpdateTexture}
|
|
||||||
>
|
|
||||||
{t('admin.changeTexture')}
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-danger" onClick={props.onDelete}>
|
|
||||||
{t('admin.deletePlayer')}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Row
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{player.pid}</td>
|
||||||
|
<td>
|
||||||
|
{player.name}
|
||||||
|
<span className='ml-1'>
|
||||||
|
<ButtonEdit
|
||||||
|
title={t('admin.changePlayerName')}
|
||||||
|
onClick={props.onUpdateName}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{player.uid}
|
||||||
|
<span className='ml-1'>
|
||||||
|
<ButtonEdit
|
||||||
|
title={t('admin.changeOwner')}
|
||||||
|
onClick={props.onUpdateOwner}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{player.tid_skin > 0 && (
|
||||||
|
<a
|
||||||
|
href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`}
|
||||||
|
target='_blank'
|
||||||
|
className='mr-1'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`${blessing.base_url}/preview/${player.tid_skin}`}
|
||||||
|
alt={`${player.name} - ${t('general.skin')}`}
|
||||||
|
width='64'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{player.tid_cape > 0 && (
|
||||||
|
<a
|
||||||
|
href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`${blessing.base_url}/preview/${player.tid_cape}`}
|
||||||
|
alt={`${player.name} - ${t('general.cape')}`}
|
||||||
|
width='64'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{player.last_modified}</td>
|
||||||
|
<td className='d-flex flex-wrap'>
|
||||||
|
<button
|
||||||
|
className='btn btn-default mr-2'
|
||||||
|
onClick={props.onUpdateTexture}
|
||||||
|
>
|
||||||
|
{t('admin.changeTexture')}
|
||||||
|
</button>
|
||||||
|
<button className='btn btn-danger' onClick={props.onDelete}>
|
||||||
|
{t('admin.deletePlayer')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Row;
|
||||||
|
|
|
||||||
|
|
@ -1,271 +1,276 @@
|
||||||
import React, { useState, useEffect, useLayoutEffect } from 'react'
|
import type {Paginator, Player} from '@/scripts/types';
|
||||||
import { hot } from 'react-hot-loader/root'
|
import Pagination from '@/components/Pagination';
|
||||||
import { useImmer } from 'use-immer'
|
import useIsLargeScreen from '@/scripts/hooks/useIsLargeScreen';
|
||||||
import useIsLargeScreen from '@/scripts/hooks/useIsLargeScreen'
|
import {t} from '@/scripts/i18n';
|
||||||
import { t } from '@/scripts/i18n'
|
import * as fetch from '@/scripts/net';
|
||||||
import * as fetch from '@/scripts/net'
|
import {showModal, toast} from '@/scripts/notify';
|
||||||
import type { Player, Paginator } from '@/scripts/types'
|
import urls from '@/scripts/urls';
|
||||||
import { toast, showModal } from '@/scripts/notify'
|
import {useEffect, useLayoutEffect, useState} from 'react';
|
||||||
import urls from '@/scripts/urls'
|
import {useImmer} from 'use-immer';
|
||||||
import Pagination from '@/components/Pagination'
|
import Header from '../UsersManagement/Header';
|
||||||
import Header from '../UsersManagement/Header'
|
import Card from './Card';
|
||||||
import Card from './Card'
|
import LoadingCard from './LoadingCard';
|
||||||
import LoadingCard from './LoadingCard'
|
import LoadingRow from './LoadingRow';
|
||||||
import Row from './Row'
|
import ModalUpdateTexture from './ModalUpdateTexture';
|
||||||
import LoadingRow from './LoadingRow'
|
import Row from './Row';
|
||||||
import ModalUpdateTexture from './ModalUpdateTexture'
|
|
||||||
|
|
||||||
const PlayersManagement: React.FC = () => {
|
function PlayersManagement() {
|
||||||
const [players, setPlayers] = useImmer<Player[]>([])
|
const [players, setPlayers] = useImmer<Player[]>([]);
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1)
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const isLargeScreen = useIsLargeScreen()
|
const isLargeScreen = useIsLargeScreen();
|
||||||
const [isTableMode, setIsTableMode] = useState(false)
|
const [isTableMode, setIsTableMode] = useState(false);
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('');
|
||||||
const [textureUpdating, setTextureUpdating] = useState(-1)
|
const [textureUpdating, setTextureUpdating] = useState(-1);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (isLargeScreen) {
|
if (isLargeScreen) {
|
||||||
setIsTableMode(true)
|
setIsTableMode(true);
|
||||||
}
|
}
|
||||||
}, [isLargeScreen])
|
}, [isLargeScreen]);
|
||||||
|
|
||||||
const getPlayers = async () => {
|
const getPlayers = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
const { data, last_page }: Paginator<Player> = await fetch.get(
|
const {data, last_page}: Paginator<Player> = await fetch.get(
|
||||||
urls.admin.players.list(),
|
urls.admin.players.list(),
|
||||||
{
|
{
|
||||||
q: query,
|
q: query,
|
||||||
page,
|
page: page.toString(),
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
setTotalPages(last_page)
|
setTotalPages(last_page);
|
||||||
setPlayers(() => data)
|
setPlayers(() => data);
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getPlayers()
|
getPlayers();
|
||||||
}, [page])
|
}, [page]);
|
||||||
|
|
||||||
const handleModeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleModeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setIsTableMode(event.target.value === 'table')
|
setIsTableMode(event.target.value === 'table');
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setQuery(event.target.value)
|
setQuery(event.target.value);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSubmitQuery = (event: React.FormEvent) => {
|
const handleSubmitQuery = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
getPlayers()
|
getPlayers();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleUpdateName = async (player: Player, index: number) => {
|
const handleUpdateName = async (player: Player, index: number) => {
|
||||||
let name: string
|
let name: string;
|
||||||
try {
|
try {
|
||||||
const { value } = await showModal({
|
const {value} = await showModal({
|
||||||
mode: 'prompt',
|
mode: 'prompt',
|
||||||
text: t('admin.changePlayerNameNotice'),
|
text: t('admin.changePlayerNameNotice'),
|
||||||
input: player.name,
|
input: player.name,
|
||||||
validator: (value: string) => {
|
validator(value: string) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return t('admin.emptyPlayerName')
|
return t('admin.emptyPlayerName');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
name = value
|
name = value;
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { code, message } = await fetch.put<fetch.ResponseBody>(
|
const {code, message} = await fetch.put<fetch.ResponseBody>(
|
||||||
urls.admin.players.name(player.pid),
|
urls.admin.players.name(player.pid),
|
||||||
{ player_name: name },
|
{player_name: name},
|
||||||
)
|
);
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
toast.success(message)
|
toast.success(message);
|
||||||
setPlayers((players) => {
|
setPlayers(players => {
|
||||||
players[index]!.name = name
|
players[index].name = name;
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(message)
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleUpdateOwner = async (player: Player, index: number) => {
|
const handleUpdateOwner = async (player: Player, index: number) => {
|
||||||
let uid: number
|
let uid: number;
|
||||||
try {
|
try {
|
||||||
const { value } = await showModal({
|
const {value} = await showModal({
|
||||||
mode: 'prompt',
|
mode: 'prompt',
|
||||||
text: t('admin.changePlayerOwner'),
|
text: t('admin.changePlayerOwner'),
|
||||||
input: player.uid.toString(),
|
input: player.uid.toString(),
|
||||||
inputMode: 'numeric',
|
inputMode: 'numeric',
|
||||||
})
|
});
|
||||||
uid = Number.parseInt(value)
|
uid = Number.parseInt(value);
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { code, message } = await fetch.put<fetch.ResponseBody>(
|
const {code, message} = await fetch.put<fetch.ResponseBody>(
|
||||||
urls.admin.players.owner(player.pid),
|
urls.admin.players.owner(player.pid),
|
||||||
{ uid },
|
{uid},
|
||||||
)
|
);
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
toast.success(message)
|
toast.success(message);
|
||||||
setPlayers((players) => {
|
setPlayers(players => {
|
||||||
players[index]!.uid = uid
|
players[index].uid = uid;
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(message)
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleCloseModalUpdateTexture = () => setTextureUpdating(-1)
|
const handleCloseModalUpdateTexture = () => {
|
||||||
|
setTextureUpdating(-1);
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateTexture = async (type: 'skin' | 'cape', tid: number) => {
|
const handleUpdateTexture = async (type: 'skin' | 'cape', tid: number) => {
|
||||||
const { code, message } = await fetch.put<fetch.ResponseBody>(
|
const {code, message} = await fetch.put<fetch.ResponseBody>(
|
||||||
urls.admin.players.texture(players[textureUpdating]!.pid),
|
urls.admin.players.texture(players[textureUpdating].pid),
|
||||||
{ type, tid },
|
{type, tid},
|
||||||
)
|
);
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
toast.success(message)
|
toast.success(message);
|
||||||
setPlayers((players) => {
|
setPlayers(players => {
|
||||||
const field = `tid_${type}` as const
|
const field = `tid_${type}` as const;
|
||||||
players[textureUpdating]![field] = tid
|
players[textureUpdating][field] = tid;
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(message)
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDelete = async (player: Player) => {
|
const handleDelete = async (player: Player) => {
|
||||||
try {
|
try {
|
||||||
await showModal({
|
await showModal({
|
||||||
text: t('admin.deletePlayerNotice'),
|
text: t('admin.deletePlayerNotice'),
|
||||||
okButtonType: 'danger',
|
okButtonType: 'danger',
|
||||||
})
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { code, message } = await fetch.del<fetch.ResponseBody>(
|
const {code, message} = await fetch.del<fetch.ResponseBody>(urls.admin.players.delete(player.pid));
|
||||||
urls.admin.players.delete(player.pid),
|
if (code === 0) {
|
||||||
)
|
setPlayers(players => players.filter(({pid}) => pid !== player.pid));
|
||||||
if (code === 0) {
|
toast.success(message);
|
||||||
setPlayers((players) => players.filter(({ pid }) => pid !== player.pid))
|
} else {
|
||||||
toast.success(message)
|
toast.error(message);
|
||||||
} else {
|
}
|
||||||
toast.error(message)
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className='card'>
|
||||||
<Header className="card-header">
|
<Header className='card-header'>
|
||||||
<form className="input-group" onSubmit={handleSubmitQuery}>
|
<form className='input-group' onSubmit={handleSubmitQuery}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type='text'
|
||||||
inputMode="search"
|
inputMode='search'
|
||||||
className="form-control"
|
className='form-control'
|
||||||
title={t('vendor.datatable.search')}
|
title={t('vendor.datatable.search')}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={handleQueryChange}
|
onChange={handleQueryChange}
|
||||||
/>
|
/>
|
||||||
<div className="input-group-append">
|
<div className='input-group-append'>
|
||||||
<button className="btn btn-primary" type="submit">
|
<button className='btn btn-primary' type='submit'>
|
||||||
{t('vendor.datatable.search')}
|
{t('vendor.datatable.search')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div className="btn-group btn-group-toggle">
|
<div className='btn-group btn-group-toggle'>
|
||||||
<label
|
<label
|
||||||
className={`btn btn-secondary ${isTableMode ? 'active' : ''}`}
|
className={`btn btn-secondary ${isTableMode ? 'active' : ''}`}
|
||||||
title="Table Mode"
|
title='Table Mode'
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type='radio'
|
||||||
value="table"
|
value='table'
|
||||||
checked={isTableMode}
|
checked={isTableMode}
|
||||||
onChange={handleModeChange}
|
onChange={handleModeChange}
|
||||||
/>
|
/>
|
||||||
<i className="fas fa-list"></i>
|
<i className='fas fa-list'/>
|
||||||
</label>
|
</label>
|
||||||
<label
|
<label
|
||||||
className={`btn btn-secondary ${isTableMode ? '' : 'active'}`}
|
className={`btn btn-secondary ${isTableMode ? '' : 'active'}`}
|
||||||
title="Card Mode"
|
title='Card Mode'
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type='radio'
|
||||||
value="card"
|
value='card'
|
||||||
checked={!isTableMode}
|
checked={!isTableMode}
|
||||||
onChange={handleModeChange}
|
onChange={handleModeChange}
|
||||||
/>
|
/>
|
||||||
<i className="fas fa-grip-vertical"></i>
|
<i className='fas fa-grip-vertical'/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</Header>
|
</Header>
|
||||||
{players.length === 0 && !isLoading ? (
|
{players.length === 0 && !isLoading
|
||||||
<div className="card-body text-center">{t('general.noResult')}</div>
|
? <div className='card-body text-center'>{t('general.noResult')}</div>
|
||||||
) : isTableMode ? (
|
: isTableMode
|
||||||
<div className="card-body table-responsive p-0">
|
? (
|
||||||
<table className={`table ${isLoading ? '' : 'table-striped'}`}>
|
<div className='card-body table-responsive p-0'>
|
||||||
<thead>
|
<table className={`table ${isLoading ? '' : 'table-striped'}`}>
|
||||||
<tr>
|
<thead>
|
||||||
<th>PID</th>
|
<tr>
|
||||||
<th>{t('general.player.player-name')}</th>
|
<th>PID</th>
|
||||||
<th>{t('general.player.owner')}</th>
|
<th>{t('general.player.player-name')}</th>
|
||||||
<th>{t('general.player.previews')}</th>
|
<th>{t('general.player.owner')}</th>
|
||||||
<th>{t('general.player.last-modified')}</th>
|
<th>{t('general.player.previews')}</th>
|
||||||
<th>{t('admin.operationsTitle')}</th>
|
<th>{t('general.player.last-modified')}</th>
|
||||||
</tr>
|
<th>{t('admin.operationsTitle')}</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{isLoading
|
<tbody>
|
||||||
? new Array(10).fill(null).map((_, i) => <LoadingRow key={i} />)
|
{isLoading
|
||||||
: players.map((player, i) => (
|
? Array.from({length: 10}).fill(null).map((_, i) => <LoadingRow key={i}/>)
|
||||||
<Row
|
: players.map((player, i) => (
|
||||||
key={player.pid}
|
<Row
|
||||||
player={player}
|
key={player.pid}
|
||||||
onUpdateName={() => handleUpdateName(player, i)}
|
player={player}
|
||||||
onUpdateOwner={() => handleUpdateOwner(player, i)}
|
onUpdateName={async () => handleUpdateName(player, i)}
|
||||||
onUpdateTexture={() => setTextureUpdating(i)}
|
onUpdateOwner={async () => handleUpdateOwner(player, i)}
|
||||||
onDelete={() => handleDelete(player)}
|
onUpdateTexture={() => {
|
||||||
/>
|
setTextureUpdating(i);
|
||||||
))}
|
}}
|
||||||
</tbody>
|
onDelete={async () => handleDelete(player)}
|
||||||
</table>
|
/>
|
||||||
</div>
|
))}
|
||||||
) : (
|
</tbody>
|
||||||
<div className="card-body d-flex flex-wrap">
|
</table>
|
||||||
{isLoading
|
</div>
|
||||||
? new Array(10).fill(null).map((_, i) => <LoadingCard key={i} />)
|
)
|
||||||
: players.map((player, i) => (
|
: (
|
||||||
<Card
|
<div className='card-body d-flex flex-wrap'>
|
||||||
key={player.pid}
|
{isLoading
|
||||||
player={player}
|
? Array.from({length: 10}).fill(null).map((_, i) => <LoadingCard key={i}/>)
|
||||||
onUpdateName={() => handleUpdateName(player, i)}
|
: players.map((player, i) => (
|
||||||
onUpdateOwner={() => handleUpdateOwner(player, i)}
|
<Card
|
||||||
onUpdateTexture={() => setTextureUpdating(i)}
|
key={player.pid}
|
||||||
onDelete={() => handleDelete(player)}
|
player={player}
|
||||||
/>
|
onUpdateName={async () => handleUpdateName(player, i)}
|
||||||
))}
|
onUpdateOwner={async () => handleUpdateOwner(player, i)}
|
||||||
</div>
|
onUpdateTexture={() => {
|
||||||
)}
|
setTextureUpdating(i);
|
||||||
<div className="card-footer">
|
}}
|
||||||
<div className="float-right">
|
onDelete={async () => handleDelete(player)}
|
||||||
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
/>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
<ModalUpdateTexture
|
)}
|
||||||
open={textureUpdating > -1}
|
<div className='card-footer'>
|
||||||
onSubmit={handleUpdateTexture}
|
<div className='float-right'>
|
||||||
onClose={handleCloseModalUpdateTexture}
|
<Pagination page={page} totalPages={totalPages} onChange={setPage}/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
<ModalUpdateTexture
|
||||||
|
open={textureUpdating > -1}
|
||||||
|
onSubmit={handleUpdateTexture}
|
||||||
|
onClose={handleCloseModalUpdateTexture}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default hot(PlayersManagement)
|
export default PlayersManagement;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import styled from '@emotion/styled'
|
import * as breakpoints from '@/styles/breakpoints';
|
||||||
import * as breakpoints from '@/styles/breakpoints'
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
export const Box = styled.div`
|
export const Box = styled.div`
|
||||||
width: 48%;
|
width: 48%;
|
||||||
|
|
@ -8,4 +8,4 @@ export const Box = styled.div`
|
||||||
${breakpoints.lessThan(breakpoints.Breakpoint.lg)} {
|
${breakpoints.lessThan(breakpoints.Breakpoint.lg)} {
|
||||||
width: 98%;
|
width: 98%;
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react'
|
|
||||||
import styled from '@emotion/styled'
|
import type {Plugin} from './types';
|
||||||
import { t } from '@/scripts/i18n'
|
import {t} from '@/scripts/i18n';
|
||||||
import type { Plugin } from './types'
|
import styled from '@emotion/styled';
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx';
|
||||||
|
|
||||||
const Box = styled.div`
|
const Box = styled.div`
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
@ -15,7 +15,7 @@ const Box = styled.div`
|
||||||
.info-box-content {
|
.info-box-content {
|
||||||
max-width: calc(100% - 70px);
|
max-width: calc(100% - 70px);
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
const ActionButton = styled.a`
|
const ActionButton = styled.a`
|
||||||
transition-property: color;
|
transition-property: color;
|
||||||
transition-duration: 0.3s;
|
transition-duration: 0.3s;
|
||||||
|
|
@ -29,99 +29,102 @@ const ActionButton = styled.a`
|
||||||
&:not(:last-child) {
|
&:not(:last-child) {
|
||||||
margin-right: 9px;
|
margin-right: 9px;
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
const Header = styled.div`
|
const Header = styled.div`
|
||||||
max-width: calc(100% - 40px);
|
max-width: calc(100% - 40px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
`
|
`;
|
||||||
const Description = styled.div`
|
const Description = styled.div`
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`
|
`;
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
plugin: Plugin
|
readonly plugin: Plugin;
|
||||||
onEnable(plugin: Plugin): void
|
onEnable: (plugin: Plugin) => void;
|
||||||
onDisable(plugin: Plugin): void
|
onDisable: (plugin: Plugin) => void;
|
||||||
onDelete(plugin: Plugin): void
|
onDelete: (plugin: Plugin) => void;
|
||||||
baseUrl: string
|
readonly baseUrl: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const InfoBox: React.FC<Props> = (props) => {
|
const InfoBox: React.FC<Props> = props => {
|
||||||
const { plugin } = props
|
const {plugin} = props;
|
||||||
|
|
||||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
|
|
||||||
if (event.target.checked) {
|
if (event.target.checked) {
|
||||||
props.onEnable(plugin)
|
props.onEnable(plugin);
|
||||||
} else {
|
} else {
|
||||||
props.onDisable(plugin)
|
props.onDisable(plugin);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDelete = () => props.onDelete(plugin)
|
const handleDelete = () => {
|
||||||
|
props.onDelete(plugin);
|
||||||
|
};
|
||||||
|
|
||||||
const isDarkMode = document.body.classList.contains('dark-mode')
|
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={clsx('info-box', 'mr-3', { 'bg-gray-dark': isDarkMode })}>
|
<Box className={clsx('info-box', 'mr-3', {'bg-gray-dark': isDarkMode})}>
|
||||||
<span className={`info-box-icon bg-${plugin.icon.bg}`}>
|
<span className={`info-box-icon bg-${plugin.icon.bg}`}>
|
||||||
<i className={`${plugin.icon.faType} fa-${plugin.icon.fa}`} />
|
<i className={`${plugin.icon.faType} fa-${plugin.icon.fa}`}/>
|
||||||
</span>
|
</span>
|
||||||
<div className="info-box-content">
|
<div className='info-box-content'>
|
||||||
<div className="d-flex justify-content-between">
|
<div className='d-flex justify-content-between'>
|
||||||
<Header>
|
<Header>
|
||||||
<input
|
<input
|
||||||
className="mr-2 d-inline-block"
|
className='mr-2 d-inline-block'
|
||||||
type="checkbox"
|
type='checkbox'
|
||||||
checked={plugin.enabled}
|
checked={plugin.enabled}
|
||||||
title={
|
title={
|
||||||
plugin.enabled
|
plugin.enabled
|
||||||
? t('admin.disablePlugin')
|
? t('admin.disablePlugin')
|
||||||
: t('admin.enablePlugin')
|
: t('admin.enablePlugin')
|
||||||
}
|
}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<strong className="d-inline-block mr-2 text-truncate">
|
<strong className='d-inline-block mr-2 text-truncate'>
|
||||||
{plugin.title}
|
{plugin.title}
|
||||||
</strong>
|
</strong>
|
||||||
<span className="d-none d-sm-inline-block text-gray">
|
<span className='d-none d-sm-inline-block text-gray'>
|
||||||
v{plugin.version}
|
v
|
||||||
</span>
|
{plugin.version}
|
||||||
</Header>
|
</span>
|
||||||
<div>
|
</Header>
|
||||||
{plugin.readme && (
|
<div>
|
||||||
<ActionButton
|
{plugin.readme && (
|
||||||
href={`${props.baseUrl}/admin/plugins/readme/${plugin.name}`}
|
<ActionButton
|
||||||
title={t('admin.pluginReadme')}
|
href={`${props.baseUrl}/admin/plugins/readme/${plugin.name}`}
|
||||||
>
|
title={t('admin.pluginReadme')}
|
||||||
<i className="fas fa-question" />
|
>
|
||||||
</ActionButton>
|
<i className='fas fa-question'/>
|
||||||
)}
|
</ActionButton>
|
||||||
{plugin.enabled && plugin.config && (
|
)}
|
||||||
<ActionButton
|
{plugin.enabled && plugin.config && (
|
||||||
href={`${props.baseUrl}/admin/plugins/config/${plugin.name}`}
|
<ActionButton
|
||||||
title={t('admin.configurePlugin')}
|
href={`${props.baseUrl}/admin/plugins/config/${plugin.name}`}
|
||||||
>
|
title={t('admin.configurePlugin')}
|
||||||
<i className="fas fa-cog" />
|
>
|
||||||
</ActionButton>
|
<i className='fas fa-cog'/>
|
||||||
)}
|
</ActionButton>
|
||||||
<ActionButton
|
)}
|
||||||
href="#"
|
<ActionButton
|
||||||
title={t('admin.deletePlugin')}
|
href='#'
|
||||||
onClick={handleDelete}
|
title={t('admin.deletePlugin')}
|
||||||
>
|
onClick={handleDelete}
|
||||||
<i className="fas fa-trash" />
|
>
|
||||||
</ActionButton>
|
<i className='fas fa-trash'/>
|
||||||
</div>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
<Description className="mt-2 text-truncate" title={plugin.description}>
|
</div>
|
||||||
{plugin.description}
|
<Description className='mt-2 text-truncate' title={plugin.description}>
|
||||||
</Description>
|
{plugin.description}
|
||||||
</div>
|
</Description>
|
||||||
</Box>
|
</div>
|
||||||
)
|
</Box>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default InfoBox
|
export default InfoBox;
|
||||||
|
|
|
||||||
|
|
@ -1,247 +1,244 @@
|
||||||
import React, { useState, useEffect } from 'react'
|
import type {Plugin} from './types';
|
||||||
import { hot } from 'react-hot-loader/root'
|
import FileInput from '@/components/FileInput';
|
||||||
import { useImmer } from 'use-immer'
|
import Loading from '@/components/Loading';
|
||||||
import { t } from '@/scripts/i18n'
|
import {t} from '@/scripts/i18n';
|
||||||
import * as fetch from '@/scripts/net'
|
import * as fetch from '@/scripts/net';
|
||||||
import { toast, showModal } from '@/scripts/notify'
|
import {showModal, toast} from '@/scripts/notify';
|
||||||
import FileInput from '@/components/FileInput'
|
import {useEffect, useState} from 'react';
|
||||||
import Loading from '@/components/Loading'
|
import {useImmer} from 'use-immer';
|
||||||
import InfoBox from './InfoBox'
|
import InfoBox from './InfoBox';
|
||||||
import type { Plugin } from './types'
|
|
||||||
|
|
||||||
const PluginsManagement: React.FC = () => {
|
function PluginsManagement() {
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [plugins, setPlugins] = useImmer<Plugin[]>([])
|
const [plugins, setPlugins] = useImmer<Plugin[]>([]);
|
||||||
const [file, setFile] = useState<File | null>(null)
|
const [file, setFile] = useState<File | undefined>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('');
|
||||||
const [isDownloading, setIsDownloading] = useState(false)
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getPlugins = async () => {
|
const getPlugins = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data')
|
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data');
|
||||||
setPlugins(() => plugins)
|
setPlugins(() => plugins);
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
getPlugins()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleEnable = async (plugin: Plugin, i: number) => {
|
getPlugins();
|
||||||
const {
|
}, []);
|
||||||
code,
|
|
||||||
message,
|
|
||||||
data: { reason } = { reason: [] },
|
|
||||||
} = await fetch.post<
|
|
||||||
fetch.ResponseBody<{
|
|
||||||
reason: string[]
|
|
||||||
}>
|
|
||||||
>('/admin/plugins/manage', {
|
|
||||||
action: 'enable',
|
|
||||||
name: plugin.name,
|
|
||||||
})
|
|
||||||
if (code === 0) {
|
|
||||||
toast.success(message)
|
|
||||||
setPlugins((plugins) => {
|
|
||||||
plugins[i]!.enabled = true
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
showModal({
|
|
||||||
mode: 'alert',
|
|
||||||
children: (
|
|
||||||
<div>
|
|
||||||
<p>{message}</p>
|
|
||||||
<ul>
|
|
||||||
{reason.map((t, i) => (
|
|
||||||
<li key={i}>{t}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDisable = async (plugin: Plugin, i: number) => {
|
const handleEnable = async (plugin: Plugin, i: number) => {
|
||||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
const {
|
||||||
'/admin/plugins/manage',
|
code,
|
||||||
{
|
message,
|
||||||
action: 'disable',
|
data: {reason} = {reason: []},
|
||||||
name: plugin.name,
|
} = await fetch.post<
|
||||||
},
|
fetch.ResponseBody<{
|
||||||
)
|
reason: string[];
|
||||||
if (code === 0) {
|
}>
|
||||||
toast.success(message)
|
>('/admin/plugins/manage', {
|
||||||
setPlugins((plugins) => {
|
action: 'enable',
|
||||||
plugins[i]!.enabled = false
|
name: plugin.name,
|
||||||
})
|
});
|
||||||
} else {
|
if (code === 0) {
|
||||||
toast.error(message)
|
toast.success(message);
|
||||||
}
|
setPlugins(plugins => {
|
||||||
}
|
plugins[i].enabled = true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showModal({
|
||||||
|
mode: 'alert',
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<p>{message}</p>
|
||||||
|
<ul>
|
||||||
|
{reason.map((t, i) =>
|
||||||
|
<li key={i}>{t}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (plugin: Plugin) => {
|
const handleDisable = async (plugin: Plugin, i: number) => {
|
||||||
try {
|
const {code, message} = await fetch.post<fetch.ResponseBody>(
|
||||||
await showModal({
|
'/admin/plugins/manage',
|
||||||
title: plugin.title,
|
{
|
||||||
text: t('admin.confirmDeletion'),
|
action: 'disable',
|
||||||
okButtonType: 'danger',
|
name: plugin.name,
|
||||||
})
|
},
|
||||||
} catch {
|
);
|
||||||
return
|
if (code === 0) {
|
||||||
}
|
toast.success(message);
|
||||||
|
setPlugins(plugins => {
|
||||||
|
plugins[i].enabled = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
const handleDelete = async (plugin: Plugin) => {
|
||||||
'/admin/plugins/manage',
|
try {
|
||||||
{
|
await showModal({
|
||||||
action: 'delete',
|
title: plugin.title,
|
||||||
name: plugin.name,
|
text: t('admin.confirmDeletion'),
|
||||||
},
|
okButtonType: 'danger',
|
||||||
)
|
});
|
||||||
if (code === 0) {
|
} catch {
|
||||||
const { name } = plugin
|
return;
|
||||||
setPlugins((plugins) => plugins.filter((plugin) => plugin.name !== name))
|
}
|
||||||
toast.success(message)
|
|
||||||
} else {
|
|
||||||
toast.error(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const {code, message} = await fetch.post<fetch.ResponseBody>(
|
||||||
setFile(event.target.files![0]!)
|
'/admin/plugins/manage',
|
||||||
}
|
{
|
||||||
|
action: 'delete',
|
||||||
|
name: plugin.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (code === 0) {
|
||||||
|
const {name} = plugin;
|
||||||
|
setPlugins(plugins => plugins.filter(plugin => plugin.name !== name));
|
||||||
|
toast.success(message);
|
||||||
|
} else {
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setUrl(event.target.value)
|
setFile(event.target.files![0]);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!file) {
|
setUrl(event.target.value);
|
||||||
return
|
};
|
||||||
}
|
|
||||||
|
|
||||||
setIsUploading(true)
|
const handleUpload = async () => {
|
||||||
const formData = new FormData()
|
if (!file) {
|
||||||
formData.append('file', file, file.name)
|
return;
|
||||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
}
|
||||||
'/admin/plugins/upload',
|
|
||||||
formData,
|
|
||||||
)
|
|
||||||
|
|
||||||
setIsUploading(false)
|
setIsUploading(true);
|
||||||
if (code === 0) {
|
const formData = new FormData();
|
||||||
toast.success(message)
|
formData.append('file', file, file.name);
|
||||||
setFile(null)
|
const {code, message} = await fetch.post<fetch.ResponseBody>(
|
||||||
|
'/admin/plugins/upload',
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
|
||||||
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data')
|
setIsUploading(false);
|
||||||
setPlugins(() => plugins)
|
if (code === 0) {
|
||||||
} else {
|
toast.success(message);
|
||||||
toast.error(message)
|
setFile(null);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmitUrl = async () => {
|
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data');
|
||||||
setIsDownloading(true)
|
setPlugins(() => plugins);
|
||||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
} else {
|
||||||
'/admin/plugins/wget',
|
toast.error(message);
|
||||||
{ url },
|
}
|
||||||
)
|
};
|
||||||
|
|
||||||
setIsDownloading(false)
|
const handleSubmitUrl = async () => {
|
||||||
if (code === 0) {
|
setIsDownloading(true);
|
||||||
toast.success(message)
|
const {code, message} = await fetch.post<fetch.ResponseBody>(
|
||||||
setUrl('')
|
'/admin/plugins/wget',
|
||||||
|
{url},
|
||||||
|
);
|
||||||
|
|
||||||
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data')
|
setIsDownloading(false);
|
||||||
setPlugins(() => plugins)
|
if (code === 0) {
|
||||||
} else {
|
toast.success(message);
|
||||||
toast.error(message)
|
setUrl('');
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks = Array(Math.ceil(plugins.length / 2))
|
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data');
|
||||||
.fill(null)
|
setPlugins(() => plugins);
|
||||||
.map((_, i) => plugins.slice(i * 2, (i + 1) * 2) as [Plugin, Plugin?])
|
} else {
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const chunks = Array.from({length: Math.ceil(plugins.length / 2)})
|
||||||
<div className="row">
|
.fill(null)
|
||||||
<div className="col-lg-8">
|
.map((_, i) => plugins.slice(i * 2, (i + 1) * 2) as [Plugin, Plugin?]);
|
||||||
{isLoading ? (
|
|
||||||
<Loading />
|
return (
|
||||||
) : plugins.length === 0 ? (
|
<div className='row'>
|
||||||
t('general.noResult')
|
<div className='col-lg-8'>
|
||||||
) : (
|
{isLoading
|
||||||
chunks.map((chunk, i) => (
|
? <Loading/>
|
||||||
<div className="row" key={`${chunk[0].name}&${chunk[1]?.name}`}>
|
: plugins.length === 0
|
||||||
{(chunk as Plugin[]).map((plugin, j) => (
|
? t('general.noResult')
|
||||||
<div className="col-md-6" key={plugin.name}>
|
: chunks.map((chunk, i) => (
|
||||||
<InfoBox
|
<div key={`${chunk[0].name}&${chunk[1]?.name}`} className='row'>
|
||||||
plugin={plugin}
|
{(chunk as Plugin[]).map((plugin, index) => (
|
||||||
onEnable={(plugin) => handleEnable(plugin, i * 2 + j)}
|
<div key={plugin.name} className='col-md-6'>
|
||||||
onDisable={(plugin) => handleDisable(plugin, i * 2 + j)}
|
<InfoBox
|
||||||
onDelete={handleDelete}
|
plugin={plugin}
|
||||||
baseUrl={blessing.base_url}
|
baseUrl={blessing.base_url}
|
||||||
/>
|
onEnable={async plugin => handleEnable(plugin, i * 2 + index)}
|
||||||
</div>
|
onDisable={async plugin => handleDisable(plugin, i * 2 + index)}
|
||||||
))}
|
onDelete={handleDelete}
|
||||||
</div>
|
/>
|
||||||
))
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-4">
|
))}
|
||||||
<div className="card card-primary card-outline">
|
</div>
|
||||||
<div className="card-header">
|
<div className='col-lg-4'>
|
||||||
<h3 className="card-title">{t('admin.uploadArchive')}</h3>
|
<div className='card card-primary card-outline'>
|
||||||
</div>
|
<div className='card-header'>
|
||||||
<div className="card-body">
|
<h3 className='card-title'>{t('admin.uploadArchive')}</h3>
|
||||||
<p>{t('admin.uploadArchiveNotice')}</p>
|
</div>
|
||||||
<FileInput
|
<div className='card-body'>
|
||||||
file={file}
|
<p>{t('admin.uploadArchiveNotice')}</p>
|
||||||
accept="application/zip"
|
<FileInput
|
||||||
onChange={handleFileChange}
|
file={file}
|
||||||
/>
|
accept='application/zip'
|
||||||
</div>
|
onChange={handleFileChange}
|
||||||
<div className="card-footer">
|
/>
|
||||||
<button
|
</div>
|
||||||
className="btn btn-primary float-right"
|
<div className='card-footer'>
|
||||||
disabled={isUploading}
|
<button
|
||||||
onClick={handleUpload}
|
className='btn btn-primary float-right'
|
||||||
>
|
disabled={isUploading}
|
||||||
{isUploading ? <Loading /> : t('general.submit')}
|
onClick={handleUpload}
|
||||||
</button>
|
>
|
||||||
</div>
|
{isUploading ? <Loading/> : t('general.submit')}
|
||||||
</div>
|
</button>
|
||||||
<div className="card card-primary card-outline">
|
</div>
|
||||||
<div className="card-header">
|
</div>
|
||||||
<h3 className="card-title">{t('admin.downloadRemote')}</h3>
|
<div className='card card-primary card-outline'>
|
||||||
</div>
|
<div className='card-header'>
|
||||||
<div className="card-body">
|
<h3 className='card-title'>{t('admin.downloadRemote')}</h3>
|
||||||
<p>{t('admin.downloadRemoteNotice')}</p>
|
</div>
|
||||||
<div className="form-group">
|
<div className='card-body'>
|
||||||
<label htmlFor="zip-url">URL</label>
|
<p>{t('admin.downloadRemoteNotice')}</p>
|
||||||
<input
|
<div className='form-group'>
|
||||||
type="text"
|
<label htmlFor='zip-url'>URL</label>
|
||||||
id="zip-url"
|
<input
|
||||||
className="form-control"
|
type='text'
|
||||||
inputMode="url"
|
id='zip-url'
|
||||||
value={url}
|
className='form-control'
|
||||||
onChange={handleUrlChange}
|
inputMode='url'
|
||||||
/>
|
value={url}
|
||||||
</div>
|
onChange={handleUrlChange}
|
||||||
</div>
|
/>
|
||||||
<div className="card-footer">
|
</div>
|
||||||
<button
|
</div>
|
||||||
className="btn btn-primary float-right"
|
<div className='card-footer'>
|
||||||
disabled={isDownloading}
|
<button
|
||||||
onClick={handleSubmitUrl}
|
className='btn btn-primary float-right'
|
||||||
>
|
disabled={isDownloading}
|
||||||
{isDownloading ? <Loading /> : t('general.submit')}
|
onClick={handleSubmitUrl}
|
||||||
</button>
|
>
|
||||||
</div>
|
{isDownloading ? <Loading/> : t('general.submit')}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default hot(PluginsManagement)
|
export default PluginsManagement;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
export type Plugin = {
|
export type Plugin = {
|
||||||
name: string
|
name: string;
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
version: string
|
version: string;
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
config: boolean
|
config: boolean;
|
||||||
readme: boolean
|
readme: boolean;
|
||||||
icon: { fa: string; faType: 'fas' | 'fab'; bg: string }
|
icon: {fa: string; faType: 'fas' | 'fab'; bg: string};
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,92 +1,100 @@
|
||||||
import React from 'react'
|
|
||||||
import { t } from '@/scripts/i18n'
|
|
||||||
import type { Plugin } from './types'
|
|
||||||
|
|
||||||
interface Props {
|
import type {Plugin} from './types';
|
||||||
plugin: Plugin
|
import {t} from '@/scripts/i18n';
|
||||||
isInstalling: boolean
|
|
||||||
onInstall(): void
|
|
||||||
onUpdate(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const Row: React.FC<Props> = (props) => {
|
type Props = {
|
||||||
const { plugin, isInstalling } = props
|
readonly plugin: Plugin;
|
||||||
|
readonly isInstalling: boolean;
|
||||||
|
onInstall: () => void;
|
||||||
|
onUpdate: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
const allDeps = Object.entries(plugin.dependencies.all)
|
const Row: React.FC<Props> = props => {
|
||||||
const unsatisfied = Object.keys(plugin.dependencies.unsatisfied)
|
const {plugin, isInstalling} = props;
|
||||||
|
|
||||||
return (
|
const allDeps = Object.entries(plugin.dependencies.all);
|
||||||
<tr>
|
const unsatisfied = Object.keys(plugin.dependencies.unsatisfied);
|
||||||
<td style={{ width: '18%' }}>
|
|
||||||
<div>
|
|
||||||
<b>{plugin.title}</b>
|
|
||||||
</div>
|
|
||||||
<div>{plugin.name}</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ width: '37%' }}>{plugin.description}</td>
|
|
||||||
<td>{plugin.author}</td>
|
|
||||||
<td>{plugin.version}</td>
|
|
||||||
<td style={{ width: '100px' }}>
|
|
||||||
{allDeps.length === 0 ? (
|
|
||||||
<i>{t('admin.noDependencies')}</i>
|
|
||||||
) : (
|
|
||||||
<div className="d-flex flex-column">
|
|
||||||
{allDeps.map(([name, constraint]) => {
|
|
||||||
const classes = [
|
|
||||||
'mb-1',
|
|
||||||
'badge',
|
|
||||||
`bg-${unsatisfied.includes(name) ? 'red' : 'green'}`,
|
|
||||||
]
|
|
||||||
return (
|
|
||||||
<span key={name} className={classes.join(' ')}>
|
|
||||||
{name}: {constraint}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{ width: '12%' }}>
|
|
||||||
{plugin.can_update ? (
|
|
||||||
<button
|
|
||||||
className="btn btn-success"
|
|
||||||
disabled={isInstalling}
|
|
||||||
onClick={props.onUpdate}
|
|
||||||
>
|
|
||||||
{isInstalling ? (
|
|
||||||
<>
|
|
||||||
<i className="fas fa-spinner fa-spin mr-1"></i>
|
|
||||||
{t('admin.pluginUpdating')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<i className="fas fa-sync-alt mr-1"></i>
|
|
||||||
{t('admin.updatePlugin')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="btn btn-default"
|
|
||||||
disabled={props.isInstalling || !!plugin.installed}
|
|
||||||
onClick={props.onInstall}
|
|
||||||
>
|
|
||||||
{isInstalling ? (
|
|
||||||
<>
|
|
||||||
<i className="fas fa-spinner fa-spin mr-1"></i>
|
|
||||||
{t('admin.pluginInstalling')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<i className="fas fa-download mr-1"></i>
|
|
||||||
{t('admin.installPlugin')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Row
|
return (
|
||||||
|
<tr>
|
||||||
|
<td style={{width: '18%'}}>
|
||||||
|
<div>
|
||||||
|
<b>{plugin.title}</b>
|
||||||
|
</div>
|
||||||
|
<div>{plugin.name}</div>
|
||||||
|
</td>
|
||||||
|
<td style={{width: '37%'}}>{plugin.description}</td>
|
||||||
|
<td>{plugin.author}</td>
|
||||||
|
<td>{plugin.version}</td>
|
||||||
|
<td style={{width: '100px'}}>
|
||||||
|
{allDeps.length === 0
|
||||||
|
? <i>{t('admin.noDependencies')}</i>
|
||||||
|
: (
|
||||||
|
<div className='d-flex flex-column'>
|
||||||
|
{allDeps.map(([name, constraint]) => {
|
||||||
|
const classes = [
|
||||||
|
'mb-1',
|
||||||
|
'badge',
|
||||||
|
`bg-${unsatisfied.includes(name) ? 'red' : 'green'}`,
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<span key={name} className={classes.join(' ')}>
|
||||||
|
{name}
|
||||||
|
:
|
||||||
|
{constraint}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{width: '12%'}}>
|
||||||
|
{plugin.can_update
|
||||||
|
? (
|
||||||
|
<button
|
||||||
|
className='btn btn-success'
|
||||||
|
disabled={isInstalling}
|
||||||
|
onClick={props.onUpdate}
|
||||||
|
>
|
||||||
|
{isInstalling
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<i className='fas fa-spinner fa-spin mr-1'/>
|
||||||
|
{t('admin.pluginUpdating')}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<i className='fas fa-sync-alt mr-1'/>
|
||||||
|
{t('admin.updatePlugin')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
className='btn btn-default'
|
||||||
|
disabled={props.isInstalling || Boolean(plugin.installed)}
|
||||||
|
onClick={props.onInstall}
|
||||||
|
>
|
||||||
|
{isInstalling
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<i className='fas fa-spinner fa-spin mr-1'/>
|
||||||
|
{t('admin.pluginInstalling')}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<i className='fas fa-download mr-1'/>
|
||||||
|
{t('admin.installPlugin')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Row;
|
||||||
|
|
|
||||||
|
|
@ -1,167 +1,163 @@
|
||||||
import React, { useState, useEffect, useMemo } from 'react'
|
import type {Plugin} from './types';
|
||||||
import { hot } from 'react-hot-loader/root'
|
import Loading from '@/components/Loading';
|
||||||
import { enableMapSet } from 'immer'
|
import Pagination from '@/components/Pagination';
|
||||||
import { useImmer } from 'use-immer'
|
import {t} from '@/scripts/i18n';
|
||||||
import { t } from '@/scripts/i18n'
|
import * as fetch from '@/scripts/net';
|
||||||
import * as fetch from '@/scripts/net'
|
import {showModal, toast} from '@/scripts/notify';
|
||||||
import { toast, showModal } from '@/scripts/notify'
|
import {enableMapSet} from 'immer';
|
||||||
import Loading from '@/components/Loading'
|
import {useEffect, useMemo, useState} from 'react';
|
||||||
import Pagination from '@/components/Pagination'
|
import {useImmer} from 'use-immer';
|
||||||
import type { Plugin } from './types'
|
import Row from './Row';
|
||||||
import Row from './Row'
|
|
||||||
|
|
||||||
enableMapSet()
|
enableMapSet();
|
||||||
|
|
||||||
const PluginsMarket: React.FC = () => {
|
export default function PluginsMarket() {
|
||||||
const [plugins, setPlugins] = useImmer<Plugin[]>([])
|
const [plugins, setPlugins] = useImmer<Plugin[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1)
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [installings, setInstallings] = useImmer<Set<string>>(() => new Set())
|
const [installings, setInstallings] = useImmer<Set<string>>(() => new Set());
|
||||||
|
|
||||||
const searchedPlugins = useMemo(
|
const searchedPlugins = useMemo(
|
||||||
() =>
|
() =>
|
||||||
plugins.filter(
|
plugins.filter(plugin =>
|
||||||
(plugin) =>
|
plugin.name.includes(search) || plugin.title.includes(search)),
|
||||||
plugin.name.includes(search) || plugin.title.includes(search),
|
[plugins, search],
|
||||||
),
|
);
|
||||||
[plugins, search],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getPlugins = async () => {
|
const getPlugins = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
const plugins = await fetch.get<Plugin[]>('/admin/plugins/market/list')
|
const plugins = await fetch.get<Plugin[]>('/admin/plugins/market/list');
|
||||||
setPlugins(() => plugins)
|
setPlugins(() => plugins);
|
||||||
setTotalPages(Math.ceil(plugins.length / 10))
|
setTotalPages(Math.ceil(plugins.length / 10));
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
getPlugins()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
void getPlugins();
|
||||||
const search = event.target.value
|
}, []);
|
||||||
setSearch(search)
|
|
||||||
setPage(1)
|
|
||||||
|
|
||||||
const searchedPlugins = plugins.filter(
|
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
(plugin) => plugin.name.includes(search) || plugin.title.includes(search),
|
const search = event.target.value;
|
||||||
)
|
setSearch(search);
|
||||||
setTotalPages(Math.ceil(searchedPlugins.length / 10))
|
setPage(1);
|
||||||
}
|
|
||||||
|
|
||||||
const handleInstall = async (plugin: Plugin, index: number) => {
|
const searchedPlugins = plugins.filter(plugin => plugin.name.includes(search) || plugin.title.includes(search));
|
||||||
setInstallings((installings) => {
|
setTotalPages(Math.ceil(searchedPlugins.length / 10));
|
||||||
installings.add(plugin.name)
|
};
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
const handleInstall = async (plugin: Plugin, index: number) => {
|
||||||
code,
|
setInstallings(installings => {
|
||||||
message,
|
installings.add(plugin.name);
|
||||||
data = { reason: [] },
|
});
|
||||||
} = await fetch.post<fetch.ResponseBody<{ reason: string[] }>>(
|
|
||||||
'/admin/plugins/market/download',
|
|
||||||
{
|
|
||||||
name: plugin.name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (code === 0) {
|
|
||||||
toast.success(message)
|
|
||||||
setPlugins((plugins) => {
|
|
||||||
plugins[index]!.can_update = false
|
|
||||||
plugins[index]!.installed = plugins[index]!.version
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
showModal({
|
|
||||||
mode: 'alert',
|
|
||||||
children: (
|
|
||||||
<div>
|
|
||||||
<p>{message}</p>
|
|
||||||
<ul>
|
|
||||||
{data.reason.map((t, i) => (
|
|
||||||
<li key={i}>{t}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setInstallings((installings) => {
|
const {
|
||||||
installings.delete(plugin.name)
|
code,
|
||||||
})
|
message,
|
||||||
}
|
data = {reason: []},
|
||||||
|
} = await fetch.post<fetch.ResponseBody<{reason: string[]}>>(
|
||||||
|
'/admin/plugins/market/download',
|
||||||
|
{
|
||||||
|
name: plugin.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (code === 0) {
|
||||||
|
toast.success(message);
|
||||||
|
setPlugins(plugins => {
|
||||||
|
plugins[index].can_update = false;
|
||||||
|
plugins[index].installed = plugins[index].version;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
void showModal({
|
||||||
|
mode: 'alert',
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<p>{message}</p>
|
||||||
|
<ul>
|
||||||
|
{data.reason.map((t, i) =>
|
||||||
|
<li key={i}>{t}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const handleUpdate = async (plugin: Plugin, index: number) => {
|
setInstallings(installings => {
|
||||||
try {
|
installings.delete(plugin.name);
|
||||||
await showModal({
|
});
|
||||||
text: t('admin.confirmUpdate', {
|
};
|
||||||
plugin: plugin.title,
|
|
||||||
old: plugin.installed,
|
|
||||||
new: plugin.version,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInstall(plugin, index)
|
const handleUpdate = async (plugin: Plugin, index: number) => {
|
||||||
}
|
try {
|
||||||
|
await showModal({
|
||||||
|
text: t('admin.confirmUpdate', {
|
||||||
|
plugin: plugin.title,
|
||||||
|
old: plugin.installed.toString(),
|
||||||
|
new: plugin.version,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pagedPlugins = searchedPlugins.slice((page - 1) * 10, page * 10)
|
void handleInstall(plugin, index);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const pagedPlugins = searchedPlugins.slice((page - 1) * 10, page * 10);
|
||||||
<div className="card">
|
|
||||||
<div className="card-header">
|
return (
|
||||||
<input
|
<div className='card'>
|
||||||
type="text"
|
<div className='card-header'>
|
||||||
className="form-control"
|
<input
|
||||||
placeholder={t('vendor.datatable.search')}
|
type='text'
|
||||||
value={search}
|
className='form-control'
|
||||||
onChange={handleSearchChange}
|
placeholder={t('vendor.datatable.search')}
|
||||||
/>
|
value={search}
|
||||||
</div>
|
onChange={handleSearchChange}
|
||||||
{isLoading ? (
|
/>
|
||||||
<div className="card-body">
|
</div>
|
||||||
<Loading />
|
{isLoading
|
||||||
</div>
|
? (
|
||||||
) : searchedPlugins.length === 0 ? (
|
<div className='card-body'>
|
||||||
<div className="card-body text-center">{t('general.noResult')}</div>
|
<Loading/>
|
||||||
) : (
|
</div>
|
||||||
<div className="card-body table-responsive p-0">
|
)
|
||||||
<table className="table table-striped">
|
: searchedPlugins.length === 0
|
||||||
<thead>
|
? <div className='card-body text-center'>{t('general.noResult')}</div>
|
||||||
<tr>
|
: (
|
||||||
<th>{t('admin.pluginTitle')}</th>
|
<div className='card-body table-responsive p-0'>
|
||||||
<th>{t('admin.pluginDescription')}</th>
|
<table className='table table-striped'>
|
||||||
<th>{t('admin.pluginAuthor')}</th>
|
<thead>
|
||||||
<th>{t('admin.pluginVersion')}</th>
|
<tr>
|
||||||
<th>{t('admin.pluginDependencies')}</th>
|
<th>{t('admin.pluginTitle')}</th>
|
||||||
<th>{t('admin.operationsTitle')}</th>
|
<th>{t('admin.pluginDescription')}</th>
|
||||||
</tr>
|
<th>{t('admin.pluginAuthor')}</th>
|
||||||
</thead>
|
<th>{t('admin.pluginVersion')}</th>
|
||||||
<tbody>
|
<th>{t('admin.pluginDependencies')}</th>
|
||||||
{pagedPlugins.map((plugin, i) => (
|
<th>{t('admin.operationsTitle')}</th>
|
||||||
<Row
|
</tr>
|
||||||
key={plugin.name}
|
</thead>
|
||||||
plugin={plugin}
|
<tbody>
|
||||||
isInstalling={installings.has(plugin.name)}
|
{pagedPlugins.map((plugin, i) => (
|
||||||
onInstall={() => handleInstall(plugin, (page - 1) * 10 + i)}
|
<Row
|
||||||
onUpdate={() => handleUpdate(plugin, (page - 1) * 10 + i)}
|
key={plugin.name}
|
||||||
/>
|
plugin={plugin}
|
||||||
))}
|
isInstalling={installings.has(plugin.name)}
|
||||||
</tbody>
|
onInstall={async () => handleInstall(plugin, ((page - 1) * 10) + i)}
|
||||||
</table>
|
onUpdate={async () => handleUpdate(plugin, ((page - 1) * 10) + i)}
|
||||||
</div>
|
/>
|
||||||
)}
|
))}
|
||||||
<div className="card-footer">
|
</tbody>
|
||||||
<div className="float-right">
|
</table>
|
||||||
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
<div className='card-footer'>
|
||||||
</div>
|
<div className='float-right'>
|
||||||
)
|
<Pagination page={page} totalPages={totalPages} onChange={setPage}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default hot(PluginsMarket)
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
export type Plugin = {
|
export type Plugin = {
|
||||||
name: string
|
name: string;
|
||||||
version: string
|
version: string;
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
author: string
|
author: string;
|
||||||
installed: string | false
|
installed: string | false;
|
||||||
can_update?: boolean
|
can_update?: boolean;
|
||||||
dependencies: {
|
dependencies: {
|
||||||
all: Record<string, string>
|
all: Record<string, string>;
|
||||||
unsatisfied: Record<string, string>
|
unsatisfied: Record<string, string>;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react'
|
|
||||||
import styled from '@emotion/styled'
|
import type {Texture} from '@/scripts/types';
|
||||||
import { t } from '@/scripts/i18n'
|
import {t} from '@/scripts/i18n';
|
||||||
import type { Texture } from '@/scripts/types'
|
import styled from '@emotion/styled';
|
||||||
import { Report, Status } from './types'
|
import {type Report, Status} from './types';
|
||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
width: 240px;
|
width: 240px;
|
||||||
|
|
@ -27,121 +27,131 @@ const Card = styled.div`
|
||||||
margin: 2.5px 0;
|
margin: 2.5px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
report: Report
|
readonly report: Report;
|
||||||
onClick(texture: Texture | null): void
|
onClick: (texture: Texture | undefined) => void;
|
||||||
onBan(): void
|
onBan: () => void;
|
||||||
onDelete(): void
|
onDelete: () => void;
|
||||||
onReject(): void
|
onReject: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const ImageBox: React.FC<Props> = (props) => {
|
const ImageBox: React.FC<Props> = props => {
|
||||||
const { report } = props
|
const {report} = props;
|
||||||
const preview = `${blessing.base_url}/preview/${report.tid}?height=150`
|
const preview = `${blessing.base_url}/preview/${report.tid}?height=150`;
|
||||||
const previewPNG = `${preview}&png`
|
const previewPNG = `${preview}&png`;
|
||||||
|
|
||||||
const handleImageClick = () => props.onClick(report.texture)
|
const handleImageClick = () => {
|
||||||
|
props.onClick(report.texture);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="card mr-3 mb-3">
|
<Card className='card mr-3 mb-3'>
|
||||||
<div className="card-header">
|
<div className='card-header'>
|
||||||
<b>
|
<b>
|
||||||
{t('skinlib.show.uploader')}
|
{t('skinlib.show.uploader')}
|
||||||
{': '}
|
{': '}
|
||||||
</b>
|
</b>
|
||||||
<span className="mr-1">{report.texture_uploader?.nickname}</span>
|
<span className='mr-1'>{report.texture_uploader?.nickname}</span>
|
||||||
(UID: {report.uploader})
|
(UID:
|
||||||
</div>
|
{' '}
|
||||||
<div className="card-body">
|
{report.uploader}
|
||||||
<picture>
|
)
|
||||||
<source srcSet={preview} type="image/webp" />
|
</div>
|
||||||
<img
|
<div className='card-body'>
|
||||||
src={previewPNG}
|
<picture>
|
||||||
alt={report.tid.toString()}
|
<source srcSet={preview} type='image/webp'/>
|
||||||
className="card-img-top"
|
<img
|
||||||
onClick={handleImageClick}
|
src={previewPNG}
|
||||||
/>
|
alt={report.tid.toString()}
|
||||||
</picture>
|
className='card-img-top'
|
||||||
</div>
|
onClick={handleImageClick}
|
||||||
<div className="card-footer">
|
/>
|
||||||
<div className="d-flex justify-content-between">
|
</picture>
|
||||||
<div>
|
</div>
|
||||||
{report.status === Status.Pending ? (
|
<div className='card-footer'>
|
||||||
<span className="badge bg-warning">{t('report.status.0')}</span>
|
<div className='d-flex justify-content-between'>
|
||||||
) : report.status === Status.Resolved ? (
|
<div>
|
||||||
<span className="badge bg-success">{t('report.status.1')}</span>
|
{report.status === Status.Pending
|
||||||
) : (
|
? <span className='badge bg-warning'>{t('report.status.0')}</span>
|
||||||
<span className="badge bg-danger">{t('report.status.2')}</span>
|
: report.status === Status.Resolved
|
||||||
)}
|
? <span className='badge bg-success'>{t('report.status.1')}</span>
|
||||||
<span className="badge bg-info ml-1">TID: {report.tid}</span>
|
: <span className='badge bg-danger'>{t('report.status.2')}</span>}
|
||||||
</div>
|
<span className='badge bg-info ml-1'>
|
||||||
<div className="dropdown">
|
TID:
|
||||||
<a
|
{report.tid}
|
||||||
className="text-gray"
|
</span>
|
||||||
href="#"
|
</div>
|
||||||
data-toggle="dropdown"
|
<div className='dropdown'>
|
||||||
aria-expanded="false"
|
<a
|
||||||
>
|
className='text-gray'
|
||||||
<i className="fas fa-cog"></i>
|
href='#'
|
||||||
</a>
|
data-toggle='dropdown'
|
||||||
<div className="dropdown-menu dropdown-menu-right">
|
aria-expanded='false'
|
||||||
<a
|
>
|
||||||
href={`${blessing.base_url}/skinlib/show/${report.tid}`}
|
<i className='fas fa-cog'/>
|
||||||
className="dropdown-item"
|
</a>
|
||||||
target="_blank"
|
<div className='dropdown-menu dropdown-menu-right'>
|
||||||
>
|
<a
|
||||||
<i className="fas fa-share-square mr-2"></i>
|
href={`${blessing.base_url}/skinlib/show/${report.tid}`}
|
||||||
{t('user.viewInSkinlib')}
|
className='dropdown-item'
|
||||||
</a>
|
target='_blank'
|
||||||
<a href="#" className="dropdown-item" onClick={props.onBan}>
|
rel='noreferrer'
|
||||||
<i className="fas fa-user-slash mr-2"></i>
|
>
|
||||||
{t('report.ban')}
|
<i className='fas fa-share-square mr-2'/>
|
||||||
</a>
|
{t('user.viewInSkinlib')}
|
||||||
<a
|
</a>
|
||||||
href="#"
|
<a href='#' className='dropdown-item' onClick={props.onBan}>
|
||||||
className="dropdown-item dropdown-item-danger"
|
<i className='fas fa-user-slash mr-2'/>
|
||||||
onClick={props.onDelete}
|
{t('report.ban')}
|
||||||
>
|
</a>
|
||||||
<i className="fas fa-trash mr-2"></i>
|
<a
|
||||||
{t('skinlib.show.delete-texture')}
|
href='#'
|
||||||
</a>
|
className='dropdown-item dropdown-item-danger'
|
||||||
<a href="#" className="dropdown-item" onClick={props.onReject}>
|
onClick={props.onDelete}
|
||||||
<i className="fas fa-thumbs-down mr-2"></i>
|
>
|
||||||
{t('report.reject')}
|
<i className='fas fa-trash mr-2'/>
|
||||||
</a>
|
{t('skinlib.show.delete-texture')}
|
||||||
</div>
|
</a>
|
||||||
</div>
|
<a href='#' className='dropdown-item' onClick={props.onReject}>
|
||||||
</div>
|
<i className='fas fa-thumbs-down mr-2'/>
|
||||||
<div>
|
{t('report.reject')}
|
||||||
<b>
|
</a>
|
||||||
{t('report.reporter')}
|
</div>
|
||||||
{': '}
|
</div>
|
||||||
</b>
|
</div>
|
||||||
<span className="mr-1">{report.informer?.nickname}</span>
|
<div>
|
||||||
(UID: {report.reporter})
|
<b>
|
||||||
</div>
|
{t('report.reporter')}
|
||||||
<details>
|
{': '}
|
||||||
<summary className="text-truncate">
|
</b>
|
||||||
<b>
|
<span className='mr-1'>{report.informer?.nickname}</span>
|
||||||
{t('report.reason')}
|
(UID:
|
||||||
{': '}
|
{' '}
|
||||||
</b>
|
{report.reporter}
|
||||||
{report.reason}
|
)
|
||||||
</summary>
|
</div>
|
||||||
<div>{report.reason}</div>
|
<details>
|
||||||
<div>
|
<summary className='text-truncate'>
|
||||||
<small>
|
<b>
|
||||||
{t('report.time')}
|
{t('report.reason')}
|
||||||
{': '}
|
{': '}
|
||||||
{report.report_at}
|
</b>
|
||||||
</small>
|
{report.reason}
|
||||||
</div>
|
</summary>
|
||||||
</details>
|
<div>{report.reason}</div>
|
||||||
</div>
|
<div>
|
||||||
</Card>
|
<small>
|
||||||
)
|
{t('report.time')}
|
||||||
}
|
{': '}
|
||||||
|
{report.report_at}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ImageBox
|
export default ImageBox;
|
||||||
|
|
|
||||||
|
|
@ -1,155 +1,156 @@
|
||||||
import React, { useState, useEffect } from 'react'
|
import type {Report, Status} from './types';
|
||||||
import { hot } from 'react-hot-loader/root'
|
import Loading from '@/components/Loading';
|
||||||
import { useImmer } from 'use-immer'
|
import Pagination from '@/components/Pagination';
|
||||||
import { t } from '@/scripts/i18n'
|
import ViewerSkeleton from '@/components/ViewerSkeleton';
|
||||||
import * as fetch from '@/scripts/net'
|
import {t} from '@/scripts/i18n';
|
||||||
import { Paginator, Texture, TextureType } from '@/scripts/types'
|
import * as fetch from '@/scripts/net';
|
||||||
import { toast, showModal } from '@/scripts/notify'
|
import {showModal, toast} from '@/scripts/notify';
|
||||||
import Loading from '@/components/Loading'
|
import {type Paginator, type Texture, TextureType} from '@/scripts/types';
|
||||||
import Pagination from '@/components/Pagination'
|
import React, {useEffect, useState} from 'react';
|
||||||
import ViewerSkeleton from '@/components/ViewerSkeleton'
|
import {useImmer} from 'use-immer';
|
||||||
import type { Report, Status } from './types'
|
import ImageBox from './ImageBox';
|
||||||
import ImageBox from './ImageBox'
|
|
||||||
|
|
||||||
const Previewer = React.lazy(() => import('@/components/Viewer'))
|
const Previewer = React.lazy(async () => import('@/components/Viewer'));
|
||||||
|
|
||||||
const ReportsManagement: React.FC = () => {
|
function ReportsManagement() {
|
||||||
const [reports, setReports] = useImmer<Report[]>([])
|
const [reports, setReports] = useImmer<Report[]>([]);
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1)
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [query, setQuery] = useState('status:0 sort:-report_at')
|
const [query, setQuery] = useState('status:0 sort:-report_at');
|
||||||
const [viewingTexture, setViewingTexture] = useState<Texture | null>(null)
|
const [viewingTexture, setViewingTexture] = useState<Texture | undefined>(null);
|
||||||
|
|
||||||
const getReports = async () => {
|
const getReports = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
const { data, last_page }: Paginator<Report> = await fetch.get(
|
const {data, last_page}: Paginator<Report> = await fetch.get(
|
||||||
'/admin/reports/list',
|
'/admin/reports/list',
|
||||||
{
|
{
|
||||||
q: query,
|
q: query,
|
||||||
page,
|
page: page.toString(),
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
setTotalPages(last_page)
|
setTotalPages(last_page);
|
||||||
setReports(() => data)
|
setReports(() => data);
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getReports()
|
getReports();
|
||||||
}, [page])
|
}, [page]);
|
||||||
|
|
||||||
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setQuery(event.target.value)
|
setQuery(event.target.value);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSubmitQuery = (event: React.FormEvent) => {
|
const handleSubmitQuery = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
getReports()
|
getReports();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleProceedReport = async (
|
const handleProceedReport = async (
|
||||||
report: Report,
|
report: Report,
|
||||||
index: number,
|
index: number,
|
||||||
action: 'ban' | 'delete' | 'reject',
|
action: 'ban' | 'delete' | 'reject',
|
||||||
) => {
|
) => {
|
||||||
type Ok = { code: 0; message: string; data: { status: Status } }
|
type Ok = {code: 0; message: string; data: {status: Status}};
|
||||||
type Err = { code: 1; message: string }
|
type Error_ = {code: 1; message: string};
|
||||||
const resp = await fetch.put<Ok | Err>(`/admin/reports/${report.id}`, {
|
const resp = await fetch.put<Ok | Error_>(`/admin/reports/${report.id}`, {
|
||||||
action,
|
action,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (resp.code === 0) {
|
if (resp.code === 0) {
|
||||||
toast.success(resp.message)
|
toast.success(resp.message);
|
||||||
setReports((reports) => {
|
setReports(reports => {
|
||||||
reports[index]!.status = resp.data.status
|
reports[index].status = resp.data.status;
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(resp.message)
|
toast.error(resp.message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDelete = async (report: Report, index: number) => {
|
const handleDelete = async (report: Report, index: number) => {
|
||||||
try {
|
try {
|
||||||
await showModal({
|
await showModal({
|
||||||
text: t('skinlib.deleteNotice'),
|
text: t('skinlib.deleteNotice'),
|
||||||
okButtonType: 'danger',
|
okButtonType: 'danger',
|
||||||
})
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleProceedReport(report, index, 'delete')
|
handleProceedReport(report, index, 'delete');
|
||||||
}
|
};
|
||||||
|
|
||||||
const textureUrl =
|
const textureUrl
|
||||||
viewingTexture && `${blessing.base_url}/textures/${viewingTexture.hash}`
|
= viewingTexture && `${blessing.base_url}/textures/${viewingTexture.hash}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row">
|
<div className='row'>
|
||||||
<div className="col-lg-8">
|
<div className='col-lg-8'>
|
||||||
<div className="card">
|
<div className='card'>
|
||||||
<div className="card-header">
|
<div className='card-header'>
|
||||||
<form className="input-group" onSubmit={handleSubmitQuery}>
|
<form className='input-group' onSubmit={handleSubmitQuery}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type='text'
|
||||||
className="form-control"
|
className='form-control'
|
||||||
title={t('vendor.datatable.search')}
|
title={t('vendor.datatable.search')}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={handleQueryChange}
|
onChange={handleQueryChange}
|
||||||
/>
|
/>
|
||||||
<div className="input-group-append">
|
<div className='input-group-append'>
|
||||||
<button className="btn btn-primary" type="submit">
|
<button className='btn btn-primary' type='submit'>
|
||||||
{t('vendor.datatable.search')}
|
{t('vendor.datatable.search')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading
|
||||||
<div className="card-body">
|
? (
|
||||||
<Loading />
|
<div className='card-body'>
|
||||||
</div>
|
<Loading/>
|
||||||
) : reports.length === 0 ? (
|
</div>
|
||||||
<div className="card-body text-center">{t('general.noResult')}</div>
|
)
|
||||||
) : (
|
: reports.length === 0
|
||||||
<div className="card-body d-flex flex-wrap">
|
? <div className='card-body text-center'>{t('general.noResult')}</div>
|
||||||
{reports.map((report, i) => (
|
: (
|
||||||
<ImageBox
|
<div className='card-body d-flex flex-wrap'>
|
||||||
key={report.id}
|
{reports.map((report, i) => (
|
||||||
report={report}
|
<ImageBox
|
||||||
onClick={setViewingTexture}
|
key={report.id}
|
||||||
onBan={() => handleProceedReport(report, i, 'ban')}
|
report={report}
|
||||||
onDelete={() => handleDelete(report, i)}
|
onClick={setViewingTexture}
|
||||||
onReject={() => handleProceedReport(report, i, 'reject')}
|
onBan={async () => handleProceedReport(report, i, 'ban')}
|
||||||
/>
|
onDelete={async () => handleDelete(report, i)}
|
||||||
))}
|
onReject={async () => handleProceedReport(report, i, 'reject')}
|
||||||
</div>
|
/>
|
||||||
)}
|
))}
|
||||||
<div className="card-footer">
|
</div>
|
||||||
<div className="float-right">
|
)}
|
||||||
<Pagination
|
<div className='card-footer'>
|
||||||
page={page}
|
<div className='float-right'>
|
||||||
totalPages={totalPages}
|
<Pagination
|
||||||
onChange={setPage}
|
page={page}
|
||||||
/>
|
totalPages={totalPages}
|
||||||
</div>
|
onChange={setPage}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-4">
|
</div>
|
||||||
<React.Suspense fallback={<ViewerSkeleton />}>
|
</div>
|
||||||
<Previewer
|
<div className='col-lg-4'>
|
||||||
{...{
|
<React.Suspense fallback={<ViewerSkeleton/>}>
|
||||||
[viewingTexture?.type === TextureType.Cape
|
<Previewer
|
||||||
? TextureType.Cape
|
{...{
|
||||||
: 'skin']: textureUrl,
|
[viewingTexture?.type === TextureType.Cape
|
||||||
}}
|
? TextureType.Cape
|
||||||
isAlex={viewingTexture?.type === TextureType.Alex}
|
: 'skin']: textureUrl,
|
||||||
/>
|
}}
|
||||||
</React.Suspense>
|
isAlex={viewingTexture?.type === TextureType.Alex}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</React.Suspense>
|
||||||
)
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default hot(ReportsManagement)
|
export default ReportsManagement;
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import type { Texture, User } from '@/scripts/types'
|
import type {Texture, User} from '@/scripts/types';
|
||||||
|
|
||||||
export const enum Status {
|
export const enum Status {
|
||||||
Pending = 0,
|
Pending = 0,
|
||||||
Resolved = 1,
|
Resolved = 1,
|
||||||
Rejected = 2,
|
Rejected = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Report = {
|
export type Report = {
|
||||||
id: number
|
id: number;
|
||||||
tid: number
|
tid: number;
|
||||||
texture: Texture | null
|
texture: Texture | undefined;
|
||||||
uploader: number
|
uploader: number;
|
||||||
texture_uploader: User | null
|
texture_uploader: User | undefined;
|
||||||
reporter: number
|
reporter: number;
|
||||||
informer: User | null
|
informer: User | undefined;
|
||||||
reason: string
|
reason: string;
|
||||||
status: Status
|
status: Status;
|
||||||
report_at: string
|
report_at: string;
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,50 @@
|
||||||
import styled from '@emotion/styled'
|
import type {Line} from './types';
|
||||||
import React from 'react'
|
import {t} from '@/scripts/i18n';
|
||||||
import { t } from '@/scripts/i18n'
|
import styled from '@emotion/styled';
|
||||||
import type { Line } from './types'
|
|
||||||
|
|
||||||
const Group = styled.td`
|
const Group = styled.td`
|
||||||
width: 15%;
|
width: 15%;
|
||||||
`
|
`;
|
||||||
const Key = styled.td`
|
const Key = styled.td`
|
||||||
width: 20%;
|
width: 20%;
|
||||||
`
|
`;
|
||||||
const Operations = styled.td`
|
const Operations = styled.td`
|
||||||
width: 25%;
|
width: 25%;
|
||||||
`
|
`;
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
line: Line
|
readonly line: Line;
|
||||||
onEdit(line: Line): void
|
onEdit: (line: Line) => void;
|
||||||
onRemove(line: Line): void
|
onRemove: (line: Line) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const Row: React.FC<Props> = (props) => {
|
const Row: React.FC<Props> = props => {
|
||||||
const { line, onEdit, onRemove } = props
|
const {line, onEdit, onRemove} = props;
|
||||||
const text = line.text[blessing.locale]
|
const text = line.text[blessing.locale];
|
||||||
|
|
||||||
const handleEditClick = () => onEdit(line)
|
const handleEditClick = () => {
|
||||||
|
onEdit(line);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveClick = () => onRemove(line)
|
const handleRemoveClick = () => {
|
||||||
|
onRemove(line);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<Group>{line.group}</Group>
|
<Group>{line.group}</Group>
|
||||||
<Key>{line.key}</Key>
|
<Key>{line.key}</Key>
|
||||||
<td>{text || t('admin.i18n.empty')}</td>
|
<td>{text || t('admin.i18n.empty')}</td>
|
||||||
<Operations>
|
<Operations>
|
||||||
<button className="btn btn-default mr-2" onClick={handleEditClick}>
|
<button className='btn btn-default mr-2' onClick={handleEditClick}>
|
||||||
{t('admin.i18n.modify')}
|
{t('admin.i18n.modify')}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-danger" onClick={handleRemoveClick}>
|
<button className='btn btn-danger' onClick={handleRemoveClick}>
|
||||||
{t('admin.i18n.delete')}
|
{t('admin.i18n.delete')}
|
||||||
</button>
|
</button>
|
||||||
</Operations>
|
</Operations>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Row
|
export default Row;
|
||||||
|
|
|
||||||
|
|
@ -1,120 +1,122 @@
|
||||||
import React, { useState, useEffect } from 'react'
|
import type {Paginator} from '@/scripts/types';
|
||||||
import { hot } from 'react-hot-loader/root'
|
import type {Line} from './types';
|
||||||
import { useImmer } from 'use-immer'
|
import Loading from '@/components/Loading';
|
||||||
import { t } from '@/scripts/i18n'
|
import Pagination from '@/components/Pagination';
|
||||||
import * as fetch from '@/scripts/net'
|
import {t} from '@/scripts/i18n';
|
||||||
import { showModal, toast } from '@/scripts/notify'
|
import * as fetch from '@/scripts/net';
|
||||||
import type { Paginator } from '@/scripts/types'
|
import {showModal, toast} from '@/scripts/notify';
|
||||||
import Loading from '@/components/Loading'
|
import React, {useEffect, useState} from 'react';
|
||||||
import Pagination from '@/components/Pagination'
|
import {useImmer} from 'use-immer';
|
||||||
import type { Line } from './types'
|
import Row from './Row';
|
||||||
import Row from './Row'
|
|
||||||
|
|
||||||
const Translations: React.FC = () => {
|
function Translations() {
|
||||||
const [lines, setLines] = useImmer<Line[]>([])
|
const [lines, setLines] = useImmer<Line[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1)
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getLines = async () => {
|
const getLines = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
const result = await fetch.get<Paginator<Line>>('/admin/i18n/list', {
|
const result = await fetch.get<Paginator<Line>>('/admin/i18n/list', {
|
||||||
page,
|
page: page.toString(),
|
||||||
})
|
});
|
||||||
setLines(() => result.data)
|
setLines(() => result.data);
|
||||||
setTotalPages(result.last_page)
|
setTotalPages(result.last_page);
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
getLines()
|
|
||||||
}, [page])
|
|
||||||
|
|
||||||
const handleEdit = async (line: Line, index: number) => {
|
getLines();
|
||||||
let text: string
|
}, [page]);
|
||||||
try {
|
|
||||||
const { value } = await showModal({
|
|
||||||
mode: 'prompt',
|
|
||||||
text: t('admin.i18n.updating'),
|
|
||||||
input: line.text[blessing.locale],
|
|
||||||
})
|
|
||||||
text = value
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { code, message } = await fetch.put<fetch.ResponseBody>(
|
const handleEdit = async (line: Line, index: number) => {
|
||||||
`/admin/i18n/${line.id}`,
|
let text: string;
|
||||||
{ text },
|
try {
|
||||||
)
|
const {value} = await showModal({
|
||||||
if (code === 0) {
|
mode: 'prompt',
|
||||||
toast.success(message)
|
text: t('admin.i18n.updating'),
|
||||||
setLines((lines) => {
|
input: line.text[blessing.locale],
|
||||||
lines[index]!.text[blessing.locale] = text
|
});
|
||||||
})
|
text = value;
|
||||||
} else {
|
} catch {
|
||||||
toast.error(message)
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemove = async (line: Line) => {
|
const {code, message} = await fetch.put<fetch.ResponseBody>(
|
||||||
try {
|
`/admin/i18n/${line.id}`,
|
||||||
await showModal({
|
{text},
|
||||||
text: t('admin.i18n.confirmDelete'),
|
);
|
||||||
okButtonType: 'danger',
|
if (code === 0) {
|
||||||
})
|
toast.success(message);
|
||||||
} catch {
|
setLines(lines => {
|
||||||
return
|
lines[index].text[blessing.locale] = text;
|
||||||
}
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { message } = await fetch.del(`/admin/i18n/${line.id}`)
|
const handleRemove = async (line: Line) => {
|
||||||
toast.success(message)
|
try {
|
||||||
const { id } = line
|
await showModal({
|
||||||
setLines((lines) => lines.filter((line) => line.id !== id))
|
text: t('admin.i18n.confirmDelete'),
|
||||||
}
|
okButtonType: 'danger',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const {message} = await fetch.del(`/admin/i18n/${line.id}`);
|
||||||
<>
|
toast.success(String(message));
|
||||||
<div className="card-body p-0">
|
const {id} = line;
|
||||||
<table className="table table-striped">
|
setLines(lines => lines.filter(line => line.id !== id));
|
||||||
<thead>
|
};
|
||||||
<tr>
|
|
||||||
<th>{t('admin.i18n.group')}</th>
|
return (
|
||||||
<th>{t('admin.i18n.key')}</th>
|
<>
|
||||||
<th>{t('admin.i18n.text')}</th>
|
<div className='card-body p-0'>
|
||||||
<th>{t('admin.operationsTitle')}</th>
|
<table className='table table-striped'>
|
||||||
</tr>
|
<thead>
|
||||||
</thead>
|
<tr>
|
||||||
<tbody>
|
<th>{t('admin.i18n.group')}</th>
|
||||||
{isLoading ? (
|
<th>{t('admin.i18n.key')}</th>
|
||||||
<tr>
|
<th>{t('admin.i18n.text')}</th>
|
||||||
<td className="text-center" colSpan={4}>
|
<th>{t('admin.operationsTitle')}</th>
|
||||||
<Loading />
|
</tr>
|
||||||
</td>
|
</thead>
|
||||||
</tr>
|
<tbody>
|
||||||
) : lines.length === 0 ? (
|
{isLoading
|
||||||
<tr>
|
? (
|
||||||
<td className="text-center" colSpan={4}>
|
<tr>
|
||||||
{t('general.noResult')}
|
<td className='text-center' colSpan={4}>
|
||||||
</td>
|
<Loading/>
|
||||||
</tr>
|
</td>
|
||||||
) : (
|
</tr>
|
||||||
lines.map((line, i) => (
|
)
|
||||||
<Row
|
: lines.length === 0
|
||||||
key={line.id}
|
? (
|
||||||
line={line}
|
<tr>
|
||||||
onEdit={(line) => handleEdit(line, i)}
|
<td className='text-center' colSpan={4}>
|
||||||
onRemove={handleRemove}
|
{t('general.noResult')}
|
||||||
/>
|
</td>
|
||||||
))
|
</tr>
|
||||||
)}
|
)
|
||||||
</tbody>
|
: lines.map((line, i) => (
|
||||||
</table>
|
<Row
|
||||||
</div>
|
key={line.id}
|
||||||
<div className="card-footer d-flex flex-row-reverse">
|
line={line}
|
||||||
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
onEdit={async line => handleEdit(line, i)}
|
||||||
</div>
|
onRemove={handleRemove}
|
||||||
</>
|
/>
|
||||||
)
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className='card-footer d-flex flex-row-reverse'>
|
||||||
|
<Pagination page={page} totalPages={totalPages} onChange={setPage}/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default hot(Translations)
|
export default Translations;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export type Line = {
|
export type Line = {
|
||||||
id: number
|
id: number;
|
||||||
group: string
|
group: string;
|
||||||
key: string
|
key: string;
|
||||||
text: Record<string, string>
|
text: Record<string, string>;
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,22 @@
|
||||||
import { post, ResponseBody } from '../../scripts/net'
|
import {t} from '../../scripts/i18n';
|
||||||
import { showModal } from '../../scripts/notify'
|
import {post, type ResponseBody} from '../../scripts/net';
|
||||||
import { t } from '../../scripts/i18n'
|
import {showModal} from '../../scripts/notify';
|
||||||
|
|
||||||
export default async function handler(event: MouseEvent) {
|
export default async function handler(event: MouseEvent) {
|
||||||
const button = event.target as HTMLButtonElement
|
const button = event.target as HTMLButtonElement;
|
||||||
button.disabled = true
|
button.disabled = true;
|
||||||
|
|
||||||
const text = button.textContent
|
const text = button.textContent;
|
||||||
button.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t(
|
button.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t('admin.downloading')}`;
|
||||||
'admin.downloading',
|
|
||||||
)}`
|
|
||||||
|
|
||||||
const { code, message }: ResponseBody = await post('/admin/update/download')
|
const {code, message}: ResponseBody = await post('/admin/update/download');
|
||||||
button.textContent = text
|
button.textContent = text;
|
||||||
button.disabled = false
|
button.disabled = false;
|
||||||
await showModal({ mode: 'alert', text: message })
|
await showModal({mode: 'alert', text: message});
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
location.href = '/'
|
location.href = '/';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const button = document.querySelector<HTMLButtonElement>('#update')
|
const button = document.querySelector<HTMLButtonElement>('#update');
|
||||||
button?.addEventListener('click', handler)
|
button?.addEventListener('click', handler);
|
||||||
|
|
|
||||||
|
|
@ -1,164 +1,167 @@
|
||||||
import React from 'react'
|
|
||||||
import { t } from '@/scripts/i18n'
|
import type {User} from '@/scripts/types';
|
||||||
import type { User } from '@/scripts/types'
|
import {t} from '@/scripts/i18n';
|
||||||
import { Box, Icon, InfoTable } from './styles'
|
import clsx from 'clsx';
|
||||||
|
import {Box, Icon, InfoTable} from './styles';
|
||||||
import {
|
import {
|
||||||
humanizePermission,
|
canModifyPermission,
|
||||||
verificationStatusText,
|
canModifyUser,
|
||||||
canModifyUser,
|
humanizePermission,
|
||||||
canModifyPermission,
|
verificationStatusText,
|
||||||
} from './utils'
|
} from './utils';
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
user: User
|
readonly user: User;
|
||||||
currentUser: User
|
readonly currentUser: User;
|
||||||
onEmailChange(): void
|
onEmailChange: () => void;
|
||||||
onNicknameChange(): void
|
onNicknameChange: () => void;
|
||||||
onScoreChange(): void
|
onScoreChange: () => void;
|
||||||
onPermissionChange(): void
|
onPermissionChange: () => void;
|
||||||
onVerificationToggle(): void
|
onVerificationToggle: () => void;
|
||||||
onPasswordChange(): void
|
onPasswordChange: () => void;
|
||||||
onDelete(): void
|
onDelete: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const Card: React.FC<Props> = (props) => {
|
const Card: React.FC<Props> = props => {
|
||||||
const { user, currentUser } = props
|
const {user, currentUser} = props;
|
||||||
|
|
||||||
const isDarkMode = document.body.classList.contains('dark-mode')
|
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||||
|
|
||||||
const avatar = `${blessing.base_url}/avatar/user/${user.uid}`
|
const avatar = `${blessing.base_url}/avatar/user/${user.uid}`;
|
||||||
const avatarPNG = `${avatar}?png`
|
const avatarPNG = `${avatar}?png`;
|
||||||
const canModify = canModifyUser(user, currentUser)
|
const canModify = canModifyUser(user, currentUser);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={clsx('info-box', { 'bg-gray-dark': isDarkMode })}>
|
<Box className={clsx('info-box', {'bg-gray-dark': isDarkMode})}>
|
||||||
<Icon py>
|
<Icon py>
|
||||||
<picture>
|
<picture>
|
||||||
<source srcSet={avatar} type="image/webp" />
|
<source srcSet={avatar} type='image/webp'/>
|
||||||
<img className="bs-avatar" src={avatarPNG} />
|
<img className='bs-avatar' src={avatarPNG}/>
|
||||||
</picture>
|
</picture>
|
||||||
</Icon>
|
</Icon>
|
||||||
<div className="info-box-content">
|
<div className='info-box-content'>
|
||||||
<div className="row">
|
<div className='row'>
|
||||||
<div className="col-10">
|
<div className='col-10'>
|
||||||
<b>{user.nickname}</b>
|
<b>{user.nickname}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-2">
|
<div className='col-2'>
|
||||||
{canModify && (
|
{canModify && (
|
||||||
<div className="float-right dropdown">
|
<div className='float-right dropdown'>
|
||||||
<a
|
<a
|
||||||
className="text-gray"
|
className='text-gray'
|
||||||
href="#"
|
href='#'
|
||||||
data-toggle="dropdown"
|
data-toggle='dropdown'
|
||||||
aria-expanded="false"
|
aria-expanded='false'
|
||||||
>
|
>
|
||||||
<i className="fas fa-cog"></i>
|
<i className='fas fa-cog'/>
|
||||||
</a>
|
</a>
|
||||||
<div className="dropdown-menu dropdown-menu-right">
|
<div className='dropdown-menu dropdown-menu-right'>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href='#'
|
||||||
className="dropdown-item"
|
className='dropdown-item'
|
||||||
onClick={props.onEmailChange}
|
onClick={props.onEmailChange}
|
||||||
>
|
>
|
||||||
<i className="fas fa-at mr-2"></i>
|
<i className='fas fa-at mr-2'/>
|
||||||
{t('admin.changeEmail')}
|
{t('admin.changeEmail')}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href='#'
|
||||||
className="dropdown-item"
|
className='dropdown-item'
|
||||||
onClick={props.onNicknameChange}
|
onClick={props.onNicknameChange}
|
||||||
>
|
>
|
||||||
<i className="fas fa-signature mr-2"></i>
|
<i className='fas fa-signature mr-2'/>
|
||||||
{t('admin.changeNickName')}
|
{t('admin.changeNickName')}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href='#'
|
||||||
className="dropdown-item"
|
className='dropdown-item'
|
||||||
onClick={props.onPasswordChange}
|
onClick={props.onPasswordChange}
|
||||||
>
|
>
|
||||||
<i className="fas fa-asterisk mr-2"></i>
|
<i className='fas fa-asterisk mr-2'/>
|
||||||
{t('admin.changePassword')}
|
{t('admin.changePassword')}
|
||||||
</a>
|
</a>
|
||||||
<div className="dropdown-divider"></div>
|
<div className='dropdown-divider'/>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href='#'
|
||||||
className="dropdown-item"
|
className='dropdown-item'
|
||||||
onClick={props.onScoreChange}
|
onClick={props.onScoreChange}
|
||||||
>
|
>
|
||||||
<i className="fas fa-coins mr-2"></i>
|
<i className='fas fa-coins mr-2'/>
|
||||||
{t('admin.changeScore')}
|
{t('admin.changeScore')}
|
||||||
</a>
|
</a>
|
||||||
{canModifyPermission(user, currentUser) && (
|
{canModifyPermission(user, currentUser) && (
|
||||||
<a
|
<a
|
||||||
href="#"
|
href='#'
|
||||||
className="dropdown-item"
|
className='dropdown-item'
|
||||||
onClick={props.onPermissionChange}
|
onClick={props.onPermissionChange}
|
||||||
>
|
>
|
||||||
<i className="fas fa-user-secret mr-2"></i>
|
<i className='fas fa-user-secret mr-2'/>
|
||||||
{t('admin.changePermission')}
|
{t('admin.changePermission')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
href="#"
|
href='#'
|
||||||
className="dropdown-item"
|
className='dropdown-item'
|
||||||
onClick={props.onVerificationToggle}
|
onClick={props.onVerificationToggle}
|
||||||
>
|
>
|
||||||
<i className="fas fa-user-check mr-2"></i>
|
<i className='fas fa-user-check mr-2'/>
|
||||||
{t('admin.toggleVerification')}
|
{t('admin.toggleVerification')}
|
||||||
</a>
|
</a>
|
||||||
<div className="dropdown-divider"></div>
|
<div className='dropdown-divider'/>
|
||||||
{canModify && user.uid !== currentUser.uid && (
|
{canModify && user.uid !== currentUser.uid && (
|
||||||
<a
|
<a
|
||||||
href="#"
|
href='#'
|
||||||
className="dropdown-item dropdown-item-danger"
|
className='dropdown-item dropdown-item-danger'
|
||||||
onClick={props.onDelete}
|
onClick={props.onDelete}
|
||||||
>
|
>
|
||||||
<i className="fas fa-trash mr-2"></i>
|
<i className='fas fa-trash mr-2'/>
|
||||||
{t('admin.deleteUser')}
|
{t('admin.deleteUser')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div>UID: {user.uid}</div>
|
<div>
|
||||||
<div>
|
UID:
|
||||||
{t('general.user.email')}
|
{user.uid}
|
||||||
{': '}
|
</div>
|
||||||
<span>{user.email}</span>
|
<div>
|
||||||
</div>
|
{t('general.user.email')}
|
||||||
<InfoTable className="row m-2 border-top border-bottom">
|
{': '}
|
||||||
<div className="col-sm-4 py-1 text-center">
|
<span>{user.email}</span>
|
||||||
<b className="d-block">{t('general.user.score')}</b>
|
</div>
|
||||||
<span className="d-block py-1">{user.score}</span>
|
<InfoTable className='row m-2 border-top border-bottom'>
|
||||||
</div>
|
<div className='col-sm-4 py-1 text-center'>
|
||||||
<div className="col-sm-4 py-1 text-center">
|
<b className='d-block'>{t('general.user.score')}</b>
|
||||||
<b className="d-block">{t('admin.permission')}</b>
|
<span className='d-block py-1'>{user.score}</span>
|
||||||
<span className="d-block py-1">
|
</div>
|
||||||
{humanizePermission(user.permission)}
|
<div className='col-sm-4 py-1 text-center'>
|
||||||
</span>
|
<b className='d-block'>{t('admin.permission')}</b>
|
||||||
</div>
|
<span className='d-block py-1'>
|
||||||
<div className="col-sm-4 py-1 text-center">
|
{humanizePermission(user.permission)}
|
||||||
<b className="d-block">{t('admin.verification')}</b>
|
</span>
|
||||||
<span className="d-block py-1">
|
</div>
|
||||||
{verificationStatusText(user.verified)}
|
<div className='col-sm-4 py-1 text-center'>
|
||||||
</span>
|
<b className='d-block'>{t('admin.verification')}</b>
|
||||||
</div>
|
<span className='d-block py-1'>
|
||||||
</InfoTable>
|
{verificationStatusText(user.verified)}
|
||||||
<div>
|
</span>
|
||||||
<small className="text-gray">
|
</div>
|
||||||
{t('general.user.register-at')}
|
</InfoTable>
|
||||||
{': '}
|
<div>
|
||||||
{user.register_at}
|
<small className='text-gray'>
|
||||||
</small>
|
{t('general.user.register-at')}
|
||||||
</div>
|
{': '}
|
||||||
</div>
|
{user.register_at}
|
||||||
</div>
|
</small>
|
||||||
</Box>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
</div>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Card
|
export default Card;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import styled from '@emotion/styled'
|
import {Breakpoint, lessThan} from '@/styles/breakpoints';
|
||||||
import { lessThan, Breakpoint } from '@/styles/breakpoints'
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
const Header = styled.div`
|
const Header = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -17,6 +17,6 @@ const Header = styled.div`
|
||||||
margin: 7px 0 0 0;
|
margin: 7px 0 0 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
export default Header
|
export default Header;
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,60 @@
|
||||||
import React from 'react'
|
import {t} from '@/scripts/i18n';
|
||||||
import styled from '@emotion/styled'
|
import styled from '@emotion/styled';
|
||||||
import Skeleton from 'react-loading-skeleton'
|
import clsx from 'clsx';
|
||||||
import { t } from '@/scripts/i18n'
|
import Skeleton from 'react-loading-skeleton';
|
||||||
import { Box, Icon, InfoTable } from './styles'
|
import {Box, Icon, InfoTable} from './styles';
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
const ShrinkedSkeleton = styled(Skeleton)<{ width?: string }>`
|
const ShrinkedSkeleton = styled(Skeleton)<{width?: string}>`
|
||||||
width: ${(props) => props.width};
|
width: ${props => props.width};
|
||||||
`
|
`;
|
||||||
|
|
||||||
const isDarkMode = document.body.classList.contains('dark-mode')
|
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||||
|
|
||||||
const LoadingCard: React.FC = () => (
|
export default function LoadingCard() {
|
||||||
<Box className={clsx('info-box', { 'bg-gray-dark': isDarkMode })}>
|
return (
|
||||||
<Icon>
|
<Box className={clsx('info-box', {'bg-gray-dark': isDarkMode})}>
|
||||||
<Skeleton circle height={50} width={50} />
|
<Icon>
|
||||||
</Icon>
|
<Skeleton circle height={50} width={50}/>
|
||||||
<div className="info-box-content">
|
</Icon>
|
||||||
<div className="row">
|
<div className='info-box-content'>
|
||||||
<div className="col-10">
|
<div className='row'>
|
||||||
<Skeleton width="140px" />
|
<div className='col-10'>
|
||||||
</div>
|
<Skeleton width='140px'/>
|
||||||
<div className="col-2"></div>
|
</div>
|
||||||
</div>
|
<div className='col-2'/>
|
||||||
<div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Skeleton width="140px" />
|
<div>
|
||||||
</div>
|
<Skeleton width='140px'/>
|
||||||
<div>
|
</div>
|
||||||
<Skeleton width="140px" />
|
<div>
|
||||||
</div>
|
<Skeleton width='140px'/>
|
||||||
<InfoTable className="row m-2 border-top border-bottom">
|
</div>
|
||||||
<div className="col-sm-4 py-1 text-center">
|
<InfoTable className='row m-2 border-top border-bottom'>
|
||||||
<b className="d-block">{t('general.user.score')}</b>
|
<div className='col-sm-4 py-1 text-center'>
|
||||||
<span className="d-block py-1">
|
<b className='d-block'>{t('general.user.score')}</b>
|
||||||
<ShrinkedSkeleton width="30%" />
|
<span className='d-block py-1'>
|
||||||
</span>
|
<ShrinkedSkeleton width='30%'/>
|
||||||
</div>
|
</span>
|
||||||
<div className="col-sm-4 py-1 text-center">
|
</div>
|
||||||
<b className="d-block">{t('admin.permission')}</b>
|
<div className='col-sm-4 py-1 text-center'>
|
||||||
<span className="d-block py-1">
|
<b className='d-block'>{t('admin.permission')}</b>
|
||||||
<ShrinkedSkeleton width="30%" />
|
<span className='d-block py-1'>
|
||||||
</span>
|
<ShrinkedSkeleton width='30%'/>
|
||||||
</div>
|
</span>
|
||||||
<div className="col-sm-4 py-1 text-center">
|
</div>
|
||||||
<b className="d-block">{t('admin.verification')}</b>
|
<div className='col-sm-4 py-1 text-center'>
|
||||||
<span className="d-block py-1">
|
<b className='d-block'>{t('admin.verification')}</b>
|
||||||
<ShrinkedSkeleton width="30%" />
|
<span className='d-block py-1'>
|
||||||
</span>
|
<ShrinkedSkeleton width='30%'/>
|
||||||
</div>
|
</span>
|
||||||
</InfoTable>
|
</div>
|
||||||
<div>
|
</InfoTable>
|
||||||
<Skeleton width="180px" />
|
<div>
|
||||||
</div>
|
<Skeleton width='180px'/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
)
|
</Box>
|
||||||
|
);
|
||||||
export default LoadingCard
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user