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
|
||||
# Append -bullseye or -buster to pin to an OS version.
|
||||
# Use -bullseye variants on local arm64/Apple Silicon.
|
||||
VARIANT: "8-bullseye"
|
||||
VARIANT: 8-bullseye
|
||||
# Optional Node.js version
|
||||
NODE_VERSION: "lts/*"
|
||||
|
||||
NODE_VERSION: 'lts/*'
|
||||
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ root = true
|
|||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
indent_style = tab
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{php,md,ps1,Dockerfile}]
|
||||
indent_size = 4
|
||||
[*.{json,yaml,yml}]
|
||||
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
|
||||
- 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:
|
||||
extensions:
|
||||
- 'editorconfig.editorconfig'
|
||||
- 'eamodio.gitlens'
|
||||
- 'bmewburn.vscode-intelephense-client'
|
||||
- 'esbenp.prettier-vscode'
|
||||
- 'jpoissonnier.vscode-styled-components'
|
||||
- 'mblode.twig-language-2'
|
||||
- 'felixfbecker.php-debug'
|
||||
- editorconfig.editorconfig
|
||||
- eamodio.gitlens
|
||||
- bmewburn.vscode-intelephense-client
|
||||
- esbenp.prettier-vscode
|
||||
- jpoissonnier.vscode-styled-components
|
||||
- mblode.twig-language-2
|
||||
- felixfbecker.php-debug
|
||||
|
||||
ports:
|
||||
- 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",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Jest Tests",
|
||||
"program": "${workspaceFolder}/node_modules/.bin/jest",
|
||||
"args": ["${file}"],
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "php",
|
||||
"request": "launch",
|
||||
"name": "Launch with XDebug",
|
||||
"ignore": [
|
||||
"**/vendor/**/*.php"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "firefox",
|
||||
"request": "launch",
|
||||
"reAttach": true,
|
||||
"name": "Launch with Firefox Debugger",
|
||||
"url": "http://localhost/",
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"pathMappings": [
|
||||
{
|
||||
"url": "webpack:///",
|
||||
"path": "${workspaceFolder}/"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Jest Tests",
|
||||
"program": "${workspaceFolder}/node_modules/.bin/jest",
|
||||
"args": ["${file}"],
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "php",
|
||||
"request": "launch",
|
||||
"name": "Launch with XDebug",
|
||||
"ignore": [
|
||||
"**/vendor/**/*.php"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "firefox",
|
||||
"request": "launch",
|
||||
"reAttach": true,
|
||||
"name": "Launch with Firefox Debugger",
|
||||
"url": "http://localhost/",
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"pathMappings": [
|
||||
{
|
||||
"url": "webpack:///",
|
||||
"path": "${workspaceFolder}/"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ class UserMenuComposer
|
|||
['label' => trans('general.admin-panel'), 'link' => route('admin.view')],
|
||||
['label' => trans('general.user-manage'), 'link' => route('admin.users.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]);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
"lorisleiva/laravel-search-string": "^1.0",
|
||||
"nesbot/carbon": "^2.0",
|
||||
"nunomaduro/collision": "^7.0",
|
||||
"rcrowe/twigbridge": "^0.14",
|
||||
"rcrowe/twigbridge": "dev-blessing",
|
||||
"spatie/laravel-translation-loader": "^2.7",
|
||||
"symfony/process": "^6.0",
|
||||
"symfony/yaml": "^5.0",
|
||||
|
|
@ -81,10 +81,14 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"repositories": {
|
||||
"packagist": {
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/bs-community/TwigBridge"
|
||||
},
|
||||
{
|
||||
"type": "composer",
|
||||
"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\Html',
|
||||
'TwigBridge\Extension\Laravel\Vite',
|
||||
// 'TwigBridge\Extension\Laravel\Legacy\Facades',
|
||||
],
|
||||
|
||||
|
|
@ -153,7 +154,8 @@ return [
|
|||
| 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",
|
||||
"type": "module",
|
||||
"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.",
|
||||
"author": "printempw",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/bs-community/blessing-skin-server"
|
||||
},
|
||||
"author": "printempw",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "webpack serve",
|
||||
"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": "vite build",
|
||||
"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": {
|
||||
"@emotion/react": "^11.0.0",
|
||||
"@emotion/styled": "^11.0.0",
|
||||
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||
"@hot-loader/react-dom": "^17.0.0",
|
||||
"@tweenjs/tween.js": "^18.5.0",
|
||||
"admin-lte": "^3.2.0",
|
||||
"blessing-skin-shell": "^0.3.4",
|
||||
"bootstrap": "^4.6.1",
|
||||
"cac": "6.6.1",
|
||||
"cli-spinners": "^2.5.0",
|
||||
"clsx": "^1.1.1",
|
||||
"echarts": "^5.1.2",
|
||||
"events": "^3.2.0",
|
||||
"immer": "^7.0.4",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"admin-lte": "4.0.0-beta3",
|
||||
"bootstrap": "^5.3.3",
|
||||
"clsx": "^2.1.1",
|
||||
"downshift": "^9.0.8",
|
||||
"echarts": "^5.6.0",
|
||||
"immer": "^10.1.1",
|
||||
"jquery": "^3.6.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"nanoid": "^3.1.9",
|
||||
"lodash-es": "^4.0.8",
|
||||
"nanoid": "^5.0.9",
|
||||
"prompts": "^2.4.0",
|
||||
"react": "^17.0.1",
|
||||
"react-autosuggest": "^10.0.2",
|
||||
"react-dom": "^17.0.1",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-draggable": "^4.4.2",
|
||||
"react-hot-loader": "^4.12.21",
|
||||
"react-loading-skeleton": "^2.1.1",
|
||||
"react-use": "^17.4.0",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-use": "^17.6.0",
|
||||
"reaptcha": "^1.7.2",
|
||||
"rxjs": "^6.5.5",
|
||||
"skinview-utils": "^0.5.5",
|
||||
"skinview3d": "^3.0.0-alpha.1",
|
||||
"spectre.css": "^0.5.8",
|
||||
"use-immer": "^0.4.2",
|
||||
"xterm": "^4.6.0",
|
||||
"xterm-addon-fit": "^0.4.0"
|
||||
"rxjs": "^7.8.1",
|
||||
"skinview-utils": "^0.7.1",
|
||||
"skinview3d": "^3.1.0",
|
||||
"spectre.css": "github:angular-package/spectre.css",
|
||||
"use-immer": "^0.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gplane/tsconfig": "^4.2.0",
|
||||
"@testing-library/jest-dom": "^5.11.10",
|
||||
"@testing-library/react": "^11.2.6",
|
||||
"@types/bootstrap": "^4.3.3",
|
||||
"@types/css-minimizer-webpack-plugin": "^1.1.0",
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/jquery": "^3.5.13",
|
||||
"@types/js-yaml": "^3.12.4",
|
||||
"@types/lodash.debounce": "^4.0.6",
|
||||
"@types/mini-css-extract-plugin": "^1.2.1",
|
||||
"@eslint-react/eslint-plugin": "^1.23.2",
|
||||
"@mochaa/eslintrc": "^0.1.12",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@tsconfig/vite-react": "^3.4.0",
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/jquery": "^3.5.32",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash-es": "^4.0.6",
|
||||
"@types/prompts": "^2.0.9",
|
||||
"@types/react": "^16.9.35",
|
||||
"@types/react-autosuggest": "^9.3.14",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/tween.js": "^18.5.0",
|
||||
"@types/webpack-dev-server": "^3.11.0",
|
||||
"@typescript-eslint/eslint-plugin": "^3.6.0",
|
||||
"@typescript-eslint/parser": "^3.6.0",
|
||||
"autoprefixer": "^10.2.6",
|
||||
"css-loader": "^5.2.6",
|
||||
"css-minimizer-webpack-plugin": "^3.0.1",
|
||||
"eslint": "^7.4.0",
|
||||
"eslint-formatter-beauty": "^3.0.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"html-webpack-plugin": "^5.3.1",
|
||||
"husky": "^7.0.4",
|
||||
"jest": "^27.0.4",
|
||||
"jest-extended": "^0.11.5",
|
||||
"js-yaml": "^3.13.1",
|
||||
"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"
|
||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"browserslist": "^4.24.4",
|
||||
"browserslist-to-esbuild": "^2.1.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.18",
|
||||
"js-yaml": "^4.1.0",
|
||||
"laravel-vite-plugin": "^1.1.1",
|
||||
"postcss": "^8.5.1",
|
||||
"sass": "^1.83.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^3.0.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"kleur": "^4.1.3"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"not dead",
|
||||
"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
|
||||
}
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"autoprefixer": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
module.exports = {
|
||||
plugins: [
|
||||
require('autoprefixer'),
|
||||
],
|
||||
}
|
||||
2
public/.gitignore
vendored
2
public/.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
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>([
|
||||
['success', 'check'],
|
||||
['info', 'info'],
|
||||
['warning', 'exclamation-triangle'],
|
||||
['danger', 'times-circle'],
|
||||
])
|
||||
['success', 'check'],
|
||||
['info', 'info'],
|
||||
['warning', 'exclamation-triangle'],
|
||||
['danger', 'times-circle'],
|
||||
]);
|
||||
|
||||
interface Props {
|
||||
type: AlertType
|
||||
}
|
||||
type Props = {
|
||||
readonly type: AlertType;
|
||||
readonly children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Alert: React.FC<Props> = (props) => {
|
||||
const { type } = props
|
||||
const icon = icons.get(type)
|
||||
const Alert: React.FC<Props> = ({type, children}) => {
|
||||
const icon = icons.get(type);
|
||||
|
||||
return props.children ? (
|
||||
<div className={`alert alert-${type}`}>
|
||||
<i className={`icon fas fa-${icon}`}></i>
|
||||
{props.children}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
return children === ''
|
||||
? null
|
||||
: (
|
||||
<div className={`alert alert-${type}`}>
|
||||
<i className={`icon fas fa-${icon}`}/>
|
||||
{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 {
|
||||
title?: string
|
||||
onClick: React.MouseEventHandler<HTMLAnchorElement>
|
||||
}
|
||||
const ButtonEdit: React.FC<Props> = ({title, onClick}) => (
|
||||
<a href='#' title={title} className='ml-2' onClick={onClick}>
|
||||
<i className='fas fa-edit'/>
|
||||
</a>
|
||||
);
|
||||
|
||||
const ButtonEdit: React.FC<Props> = (props) => (
|
||||
<a href="#" title={props.title} className="ml-2" onClick={props.onClick}>
|
||||
<i className="fas fa-edit"></i>
|
||||
</a>
|
||||
)
|
||||
|
||||
export default ButtonEdit
|
||||
export default ButtonEdit;
|
||||
|
|
|
|||
|
|
@ -1,103 +1,108 @@
|
|||
/** @jsxImportSource @emotion/react */
|
||||
import * as React from 'react'
|
||||
import Reaptcha from 'reaptcha'
|
||||
import { emit, on } from '@/scripts/event'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as cssUtils from '@/styles/utils'
|
||||
import {emit, on} from '@/scripts/event';
|
||||
import {t} from '@/scripts/i18n';
|
||||
import * as cssUtils from '@/styles/utils';
|
||||
import React from 'react';
|
||||
import Reaptcha from 'reaptcha';
|
||||
|
||||
const eventId = Symbol()
|
||||
const eventId = Symbol('EventId');
|
||||
|
||||
type State = {
|
||||
value: string
|
||||
time: number
|
||||
sitekey: string
|
||||
invisible: boolean
|
||||
}
|
||||
value: string;
|
||||
time: number;
|
||||
sitekey: string;
|
||||
invisible: boolean;
|
||||
};
|
||||
|
||||
class Captcha extends React.Component<Record<string, unknown>, State> {
|
||||
state: State
|
||||
ref: React.MutableRefObject<Reaptcha | null>
|
||||
state: State;
|
||||
// eslint-disable-next-line ts/no-restricted-types
|
||||
ref: React.RefObject<Reaptcha | null>;
|
||||
|
||||
constructor(props: Record<string, unknown>) {
|
||||
super(props)
|
||||
this.state = {
|
||||
value: '',
|
||||
time: Date.now(),
|
||||
sitekey: blessing.extra.recaptcha,
|
||||
invisible: blessing.extra.invisible,
|
||||
}
|
||||
this.ref = React.createRef()
|
||||
}
|
||||
constructor(props: Record<string, unknown>) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: '',
|
||||
time: Date.now(),
|
||||
sitekey: blessing.extra.recaptcha as string,
|
||||
invisible: blessing.extra.invisible as boolean,
|
||||
};
|
||||
this.ref = React.createRef<Reaptcha>();
|
||||
}
|
||||
|
||||
execute = async () => {
|
||||
const recaptcha = this.ref.current
|
||||
if (recaptcha && this.state.invisible) {
|
||||
return new Promise<string>((resolve) => {
|
||||
const off = on(eventId, (value: string) => {
|
||||
resolve(value)
|
||||
off()
|
||||
})
|
||||
recaptcha.execute()
|
||||
})
|
||||
}
|
||||
return this.state.value
|
||||
}
|
||||
// eslint-disable-next-line react/no-unused-class-component-members
|
||||
async execute() {
|
||||
const recaptcha = this.ref.current;
|
||||
if (recaptcha && this.state.invisible) {
|
||||
return new Promise<string>(resolve => {
|
||||
const off = on(eventId, (value: string) => {
|
||||
resolve(value);
|
||||
off();
|
||||
});
|
||||
void recaptcha.execute();
|
||||
});
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
const recaptcha = this.ref.current
|
||||
if (recaptcha) {
|
||||
recaptcha.reset()
|
||||
} else {
|
||||
this.setState({ time: Date.now() })
|
||||
}
|
||||
}
|
||||
return this.state.value;
|
||||
}
|
||||
|
||||
handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ value: event.target.value })
|
||||
}
|
||||
// eslint-disable-next-line react/no-unused-class-component-members
|
||||
reset() {
|
||||
const recaptcha = this.ref.current;
|
||||
if (recaptcha) {
|
||||
void recaptcha.reset();
|
||||
} else {
|
||||
this.setState({time: Date.now()});
|
||||
}
|
||||
}
|
||||
|
||||
handleVerify = (value: string) => {
|
||||
emit(eventId, value)
|
||||
this.setState({ value })
|
||||
}
|
||||
handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({value: event.target.value});
|
||||
};
|
||||
|
||||
handleRefresh = () => {
|
||||
this.setState({ time: Date.now() })
|
||||
}
|
||||
handleVerify = (value: string) => {
|
||||
emit(eventId, value);
|
||||
this.setState({value});
|
||||
};
|
||||
|
||||
render() {
|
||||
return this.state.sitekey ? (
|
||||
<div className="mb-2">
|
||||
<Reaptcha
|
||||
ref={this.ref}
|
||||
sitekey={this.state.sitekey}
|
||||
size={this.state.invisible ? 'invisible' : 'normal'}
|
||||
onVerify={this.handleVerify}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="d-flex">
|
||||
<div className="form-group mb-3 mr-2">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('auth.captcha')}
|
||||
required
|
||||
value={this.state.value}
|
||||
onChange={this.handleValueChange}
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
src={`${blessing.base_url}/auth/captcha?v=${this.state.time}`}
|
||||
alt={t('auth.captcha')}
|
||||
css={cssUtils.pointerCursor}
|
||||
height={34}
|
||||
title={t('auth.change-captcha')}
|
||||
onClick={this.handleRefresh}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
handleRefresh = () => {
|
||||
this.setState({time: Date.now()});
|
||||
};
|
||||
|
||||
render() {
|
||||
return this.state.sitekey
|
||||
? (
|
||||
<div className='mb-2'>
|
||||
<Reaptcha
|
||||
ref={this.ref}
|
||||
sitekey={this.state.sitekey}
|
||||
size={this.state.invisible ? 'invisible' : 'normal'}
|
||||
onVerify={this.handleVerify}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='d-flex'>
|
||||
<div className='form-group mb-3 mr-2'>
|
||||
<input
|
||||
required
|
||||
type='text'
|
||||
className='form-control'
|
||||
placeholder={t('auth.captcha')}
|
||||
value={this.state.value}
|
||||
onChange={this.handleValueChange}
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
src={`${blessing.base_url}/auth/captcha?v=${this.state.time}`}
|
||||
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 {
|
||||
initMode: boolean
|
||||
}
|
||||
type Props = {
|
||||
readonly initMode: boolean;
|
||||
};
|
||||
|
||||
const DarkModeButton: React.FC<Props> = ({ initMode }) => {
|
||||
const [darkMode, setDarkMode] = useState(initMode)
|
||||
const DarkModeButton: React.FC<Props> = ({initMode}) => {
|
||||
const [darkMode, setDarkMode] = useState(initMode);
|
||||
|
||||
const icon = darkMode ? 'moon' : 'sun'
|
||||
const icon = darkMode ? 'moon' : 'sun';
|
||||
|
||||
const handleClick = async () => {
|
||||
setDarkMode((value) => !value)
|
||||
const handleClick = async () => {
|
||||
setDarkMode(value => !value);
|
||||
|
||||
await fetch.put('/user/dark-mode')
|
||||
document.body.classList.toggle('dark-mode')
|
||||
}
|
||||
await fetch.put('/user/dark-mode');
|
||||
document.body.classList.toggle('dark-mode');
|
||||
};
|
||||
|
||||
return (
|
||||
<a className="nav-link" href="#" role="button" onClick={handleClick}>
|
||||
<i className={`fas fa-${icon}`}></i>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a className='nav-link' href='#' role='button' onClick={handleClick}>
|
||||
<i className={`fas fa-${icon}`}/>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default DarkModeButton
|
||||
export default DarkModeButton;
|
||||
|
|
|
|||
|
|
@ -1,89 +1,67 @@
|
|||
/** @jsxImportSource @emotion/react */
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Autosuggest from 'react-autosuggest'
|
||||
import { css } from '@emotion/react'
|
||||
import { emit } from '@/scripts/event'
|
||||
import { pointerCursor } from '@/styles/utils'
|
||||
import {emit} from '@/scripts/event';
|
||||
import {pointerCursor} from '@/styles/utils';
|
||||
import {css} from '@emotion/react';
|
||||
import clsx from 'clsx';
|
||||
import {useCombobox} from 'downshift';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const styles = css`
|
||||
.dropdown-menu li {
|
||||
${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'> & {
|
||||
onChange(value: string): void
|
||||
}
|
||||
type Props = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const EmailSuggestion: React.FC<Props> = (props) => {
|
||||
const [suggestions, setSuggestions] = useState<string[]>([])
|
||||
const EmailSuggestion: React.FC<Props> = props => {
|
||||
useEffect(() => {
|
||||
emit('emailDomainsSuggestion', domainNames);
|
||||
}, []);
|
||||
const [inputItems, setInputItems] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
emit('emailDomainsSuggestion', domainNames)
|
||||
}, [])
|
||||
const {
|
||||
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 =
|
||||
({ value }) => {
|
||||
const segments = value.split('@')
|
||||
setSuggestions([...domainNames].map((name) => `${segments[0]}@${name}`))
|
||||
}
|
||||
const {onChange} = props;
|
||||
onChange(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSuggestionsClearRequested = () => {
|
||||
setSuggestions([])
|
||||
}
|
||||
return (
|
||||
<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) => {
|
||||
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
|
||||
export default EmailSuggestion;
|
||||
|
|
|
|||
|
|
@ -1,53 +1,52 @@
|
|||
/** @jsxImportSource @emotion/react */
|
||||
import { useRef } from 'react'
|
||||
import { css } from '@emotion/react'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import {t} from '@/scripts/i18n';
|
||||
import {css} from '@emotion/react';
|
||||
import {useRef} from 'react';
|
||||
|
||||
const hideRawBrowseButton = css`
|
||||
::after {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
file: File | null
|
||||
accept?: string
|
||||
onChange(event: React.ChangeEvent<HTMLInputElement>): void
|
||||
}
|
||||
type Props = {
|
||||
file: File | undefined;
|
||||
accept?: string;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const FileInput: React.FC<Props> = (props) => {
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
const FileInput: React.FC<Props> = props => {
|
||||
const reference = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = () => {
|
||||
ref.current!.click()
|
||||
}
|
||||
const handleClick = () => {
|
||||
reference.current!.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label htmlFor="select-file">{t('skinlib.upload.select-file')}</label>
|
||||
<div className="input-group">
|
||||
<div className="custom-file">
|
||||
<input
|
||||
type="file"
|
||||
className="custom-file-input"
|
||||
id="select-file"
|
||||
accept={props.accept}
|
||||
title={t('skinlib.upload.select-file')}
|
||||
ref={ref}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<label className="custom-file-label" css={hideRawBrowseButton}>
|
||||
{props.file?.name}
|
||||
</label>
|
||||
</div>
|
||||
<div className="input-group-append">
|
||||
<button className="btn btn-default" onClick={handleClick}>
|
||||
{t('skinlib.upload.select-file')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className='form-group'>
|
||||
<label htmlFor='select-file'>{t('skinlib.upload.select-file')}</label>
|
||||
<div className='input-group'>
|
||||
<div className='custom-file'>
|
||||
<input
|
||||
ref={reference}
|
||||
type='file'
|
||||
className='custom-file-input'
|
||||
id='select-file'
|
||||
accept={props.accept}
|
||||
title={t('skinlib.upload.select-file')}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<label className='custom-file-label' css={hideRawBrowseButton}>
|
||||
{props.file?.name}
|
||||
</label>
|
||||
</div>
|
||||
<div className='input-group-append'>
|
||||
<button className='btn btn-default' onClick={handleClick}>
|
||||
{t('skinlib.upload.select-file')}
|
||||
</button>
|
||||
</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 = () => (
|
||||
<div className="container text-center" title="Loading...">
|
||||
<i className="fas fa-sync fa-spin"></i>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default Loading
|
||||
export default Loading;
|
||||
|
|
|
|||
|
|
@ -1,165 +1,193 @@
|
|||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import $ from 'jquery'
|
||||
import 'bootstrap'
|
||||
import { t } from '../scripts/i18n'
|
||||
import ModalHeader from './ModalHeader'
|
||||
import ModalBody from './ModalBody'
|
||||
import ModalFooter from './ModalFooter'
|
||||
import type { Props as HeaderProps } from './ModalHeader'
|
||||
import type { Props as BodyProps } from './ModalBody'
|
||||
import type { Props as FooterProps } from './ModalFooter'
|
||||
import {Modal as BootstrapModal} from 'bootstrap';
|
||||
import clsx from 'clsx';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {t} from '../scripts/i18n';
|
||||
import ModalBody, {type Props as BodyProps} from './ModalBody';
|
||||
import ModalFooter, {type Props as FooterProps} from './ModalFooter';
|
||||
import ModalHeader, {type Props as HeaderProps} from './ModalHeader';
|
||||
|
||||
type BasicOptions = {
|
||||
mode?: 'alert' | 'confirm' | 'prompt'
|
||||
show?: boolean
|
||||
input?: string
|
||||
validator?(value: any): string | boolean | undefined
|
||||
type?: string
|
||||
showHeader?: boolean
|
||||
center?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
readonly mode?: 'alert' | 'confirm' | 'prompt';
|
||||
readonly show?: boolean;
|
||||
readonly input?: string;
|
||||
validator?: (value: any) => string | boolean | undefined;
|
||||
readonly type?: string;
|
||||
readonly showHeader?: boolean;
|
||||
readonly center?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export type ModalOptions = BasicOptions & HeaderProps & BodyProps & FooterProps
|
||||
export type ModalOptions = BasicOptions & HeaderProps & BodyProps & FooterProps;
|
||||
|
||||
type Props = {
|
||||
id?: string
|
||||
children?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
onConfirm?(payload: { value: string }): void
|
||||
onDismiss?(): void
|
||||
onClose?(): void
|
||||
}
|
||||
readonly id?: string;
|
||||
readonly children?: React.ReactNode;
|
||||
readonly footer?: React.ReactNode;
|
||||
onConfirm?: (payload: {value: string}) => void;
|
||||
onDismiss?: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export type ModalResult = {
|
||||
value: string
|
||||
}
|
||||
value: string;
|
||||
};
|
||||
|
||||
const Modal: React.FC<ModalOptions & Props> = (props) => {
|
||||
const {
|
||||
mode = 'confirm',
|
||||
title = t('general.tip'),
|
||||
text = '',
|
||||
input = '',
|
||||
placeholder = '',
|
||||
inputType = 'text',
|
||||
inputMode,
|
||||
type = 'default',
|
||||
showHeader = true,
|
||||
center = false,
|
||||
okButtonText = t('general.confirm'),
|
||||
okButtonType = 'primary',
|
||||
cancelButtonText = t('general.cancel'),
|
||||
cancelButtonType = 'secondary',
|
||||
flexFooter = false,
|
||||
} = props
|
||||
const Modal: React.FC<ModalOptions & Props> = props => {
|
||||
const {
|
||||
mode = 'confirm',
|
||||
title = t('general.tip'),
|
||||
text = '',
|
||||
input = '',
|
||||
placeholder = '',
|
||||
inputType = 'text',
|
||||
inputMode,
|
||||
type = 'default',
|
||||
showHeader = true,
|
||||
center = false,
|
||||
okButtonText = t('general.confirm'),
|
||||
okButtonType = 'primary',
|
||||
cancelButtonText = t('general.cancel'),
|
||||
cancelButtonType = 'secondary',
|
||||
flexFooter = false,
|
||||
footer,
|
||||
show,
|
||||
onClose,
|
||||
onDismiss,
|
||||
id,
|
||||
validator,
|
||||
onConfirm,
|
||||
children,
|
||||
choices,
|
||||
dangerousHTML: html,
|
||||
} = props;
|
||||
|
||||
const [value, setValue] = useState(input)
|
||||
const [valid, setValid] = useState(true)
|
||||
const [validatorMessage, setValidatorMessage] = useState('')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [value, setValue] = useState(input);
|
||||
const [valid, setValid] = useState(true);
|
||||
const [validatorMessage, setValidatorMessage] = useState('');
|
||||
const reference = useRef<HTMLDivElement>(null);
|
||||
const [modal, setModal] = useState<BootstrapModal>();
|
||||
|
||||
const { show } = props
|
||||
useEffect(() => {
|
||||
if (!reference.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
return
|
||||
}
|
||||
const _modal = new BootstrapModal(reference.current);
|
||||
setModal(_modal);
|
||||
|
||||
const onHidden = () => props.onClose?.()
|
||||
return () => {
|
||||
_modal.dispose();
|
||||
};
|
||||
}, [reference]);
|
||||
|
||||
const el = $(ref.current!)
|
||||
el.on('hidden.bs.modal', onHidden)
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
return;
|
||||
}
|
||||
|
||||
return () => {
|
||||
el.off('hidden.bs.modal', onHidden)
|
||||
}
|
||||
}, [show, props.onClose])
|
||||
const onHidden = () => {
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(event.target.value)
|
||||
}
|
||||
const element = reference.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
const { validator } = props
|
||||
if (typeof validator === 'function') {
|
||||
const result = validator(value)
|
||||
if (typeof result === 'string') {
|
||||
setValidatorMessage(result)
|
||||
setValid(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
element.addEventListener('hidden.bs.modal', onHidden);
|
||||
|
||||
props.onConfirm?.({ value })
|
||||
$(ref.current!).modal('hide')
|
||||
return () => {
|
||||
element.removeEventListener('hidden.bs.modal', onHidden);
|
||||
};
|
||||
}, [reference, show, onClose]);
|
||||
|
||||
// The "hidden.bs.modal" event can't be trigged automatically when testing.
|
||||
/* istanbul ignore next */
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
$(ref.current!).trigger('hidden.bs.modal')
|
||||
}
|
||||
}
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(event.target.value);
|
||||
};
|
||||
|
||||
const dismiss = () => {
|
||||
props.onDismiss?.()
|
||||
$(ref.current!).modal('hide')
|
||||
const confirm = () => {
|
||||
if (typeof validator === 'function') {
|
||||
const result = validator(value);
|
||||
if (typeof result === 'string') {
|
||||
setValidatorMessage(result);
|
||||
setValid(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
$(ref.current!).trigger('hidden.bs.modal')
|
||||
}
|
||||
}
|
||||
onConfirm?.({value});
|
||||
modal?.hide();
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setTimeout(() => $(ref.current!).modal('show'), 50)
|
||||
}
|
||||
}, [show])
|
||||
// The "hidden.bs.modal" event can't be trigged automatically when testing.
|
||||
|
||||
if (!show) {
|
||||
return null
|
||||
}
|
||||
if (import.meta.env.NODE_ENV === 'test') {
|
||||
$(reference.current!).trigger('hidden.bs.modal');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={props.id} className="modal fade" role="dialog" ref={ref}>
|
||||
<div
|
||||
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>
|
||||
)
|
||||
}
|
||||
const dismiss = () => {
|
||||
onDismiss?.();
|
||||
modal?.hide();
|
||||
|
||||
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 {
|
||||
showInput: boolean
|
||||
}
|
||||
import ModalContent, {type Props as ContentProps} from './ModalContent';
|
||||
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> = (
|
||||
props,
|
||||
) => {
|
||||
return (
|
||||
<div className="modal-body">
|
||||
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
|
||||
{props.children}
|
||||
</ModalContent>
|
||||
{props.showInput && <ModalInput {...props} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export type Props = ContentProps & InputProps;
|
||||
|
||||
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 {
|
||||
text?: string
|
||||
dangerousHTML?: string
|
||||
}
|
||||
export type Props = {
|
||||
readonly text?: string;
|
||||
readonly dangerousHTML?: string;
|
||||
readonly children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ModalContent: React.FC<Props> = (props) => {
|
||||
if (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 }} />
|
||||
}
|
||||
const ModalContent: React.FC<Props> = props => {
|
||||
if (props.children) {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
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 {
|
||||
flexFooter?: boolean
|
||||
okButtonText?: string
|
||||
okButtonType?: string
|
||||
cancelButtonText?: string
|
||||
cancelButtonType?: string
|
||||
}
|
||||
export type Props = {
|
||||
readonly flexFooter?: boolean;
|
||||
readonly okButtonText?: string;
|
||||
readonly okButtonType?: string;
|
||||
readonly cancelButtonText?: string;
|
||||
readonly cancelButtonType?: string;
|
||||
readonly children?: React.ReactNode;
|
||||
};
|
||||
|
||||
interface InternalProps {
|
||||
showCancelButton: boolean
|
||||
onConfirm?(): void
|
||||
onDismiss?(): void
|
||||
}
|
||||
type InternalProps = {
|
||||
readonly showCancelButton: boolean;
|
||||
onConfirm?: () => void;
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
const ModalFooter: React.FC<InternalProps & Props> = (props) => {
|
||||
const classes = ['modal-footer']
|
||||
if (props.flexFooter) {
|
||||
classes.push('d-flex', 'justify-content-between')
|
||||
}
|
||||
const footerClass = classes.join(' ')
|
||||
const ModalFooter: React.FC<InternalProps & Props> = props => {
|
||||
const classes = ['modal-footer'];
|
||||
if (props.flexFooter) {
|
||||
classes.push('d-flex', 'justify-content-between');
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
const footerClass = classes.join(' ');
|
||||
|
||||
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 {
|
||||
title?: string
|
||||
}
|
||||
export type Props = {
|
||||
readonly title?: string;
|
||||
};
|
||||
|
||||
interface InternalProps {
|
||||
onDismiss?(): void
|
||||
show?: boolean
|
||||
}
|
||||
type InternalProps = {
|
||||
onDismiss?: () => void;
|
||||
readonly show?: boolean;
|
||||
};
|
||||
|
||||
const ModalHeader: React.FC<Props & InternalProps> = (props) =>
|
||||
props.show ? (
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{props.title}</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="close"
|
||||
data-dismiss="modal"
|
||||
aria-label="Close"
|
||||
onClick={props.onDismiss}
|
||||
>
|
||||
<span aria-hidden>×</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
const ModalHeader: React.FC<Props & InternalProps> = ({show, title, onDismiss}) =>
|
||||
show
|
||||
? (
|
||||
<div className='modal-header'>
|
||||
<h5 className='modal-title'>{title}</h5>
|
||||
<button
|
||||
type='button'
|
||||
className='btn-close'
|
||||
data-bs-dismiss='modal'
|
||||
aria-label='Close'
|
||||
onClick={onDismiss}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: null;
|
||||
|
||||
export default ModalHeader
|
||||
export default ModalHeader;
|
||||
|
|
|
|||
|
|
@ -1,58 +1,59 @@
|
|||
import React, { HTMLAttributes } from 'react'
|
||||
|
||||
export interface Props {
|
||||
inputType?: string
|
||||
inputMode?: HTMLAttributes<HTMLInputElement>['inputMode']
|
||||
choices?: { text: string; value: string }[]
|
||||
placeholder?: string
|
||||
}
|
||||
export type Props = {
|
||||
readonly inputType?: string;
|
||||
readonly inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
|
||||
readonly choices?: Array<{text: string; value: string}>;
|
||||
readonly placeholder?: string;
|
||||
};
|
||||
|
||||
export interface InternalProps {
|
||||
value?: string
|
||||
invalid?: boolean
|
||||
validatorMessage?: string
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>
|
||||
}
|
||||
export type InternalProps = {
|
||||
readonly value?: string;
|
||||
readonly invalid?: boolean;
|
||||
readonly validatorMessage?: string;
|
||||
readonly onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
};
|
||||
|
||||
const ModalInput: React.FC<InternalProps & Props> = (props) => (
|
||||
<>
|
||||
{props.inputType === 'radios' && props.choices ? (
|
||||
<>
|
||||
{props.choices.map((choice) => (
|
||||
<div key={choice.value}>
|
||||
<input
|
||||
type="radio"
|
||||
name="modal-radios"
|
||||
id={`modal-radio-${choice.value}`}
|
||||
value={choice.value}
|
||||
checked={choice.value === props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<label htmlFor={`modal-radio-${choice.value}`} className="ml-1">
|
||||
{choice.text}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="form-group">
|
||||
<input
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
type={props.inputType}
|
||||
inputMode={props.inputMode}
|
||||
className="form-control"
|
||||
placeholder={props.placeholder}
|
||||
></input>
|
||||
</div>
|
||||
)}
|
||||
{props.invalid && (
|
||||
<div className="alert alert-danger">
|
||||
<i className="icon far fa-times-circle"></i>
|
||||
<span className="ml-1">{props.validatorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
const ModalInput: React.FC<InternalProps & Props> = props => (
|
||||
<>
|
||||
{props.inputType === 'radios' && props.choices
|
||||
? (
|
||||
<>
|
||||
{props.choices.map(choice => (
|
||||
<div key={choice.value}>
|
||||
<input
|
||||
type='radio'
|
||||
name='modal-radios'
|
||||
id={`modal-radio-${choice.value}`}
|
||||
value={choice.value}
|
||||
checked={choice.value === props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<label htmlFor={`modal-radio-${choice.value}`} className='ml-1'>
|
||||
{choice.text}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<div className='form-group'>
|
||||
<input
|
||||
value={props.value}
|
||||
type={props.inputType}
|
||||
inputMode={props.inputMode}
|
||||
className='form-control'
|
||||
placeholder={props.placeholder}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{props.invalid && (
|
||||
<div className='alert alert-danger'>
|
||||
<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 {
|
||||
page: number
|
||||
totalPages: number
|
||||
onChange(page: number): void | Promise<void>
|
||||
}
|
||||
import {t} from '@/scripts/i18n';
|
||||
import PaginationItem from './PaginationItem';
|
||||
|
||||
type Props = {
|
||||
readonly page: number;
|
||||
readonly totalPages: number;
|
||||
onChange: (page: number) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const labels = {
|
||||
prev: '‹',
|
||||
next: '›',
|
||||
}
|
||||
prev: '‹',
|
||||
next: '›',
|
||||
};
|
||||
|
||||
const Pagination: React.FC<Props> = (props) => {
|
||||
const { page, totalPages, onChange } = props
|
||||
const Pagination: React.FC<Props> = props => {
|
||||
const {page, totalPages, onChange} = props;
|
||||
|
||||
if (totalPages < 1) {
|
||||
return null
|
||||
}
|
||||
if (totalPages < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="pagination">
|
||||
<PaginationItem
|
||||
title={t('vendor.datatable.prev')}
|
||||
disabled={page === 1}
|
||||
onClick={() => onChange(page - 1)}
|
||||
>
|
||||
{labels.prev}
|
||||
<span className="d-inline d-sm-none ml-1">
|
||||
{t('vendor.datatable.prev')}
|
||||
</span>
|
||||
</PaginationItem>
|
||||
{totalPages < 8 ? (
|
||||
Array.from({ length: totalPages }).map((_, i) => (
|
||||
<PaginationItem
|
||||
key={i}
|
||||
className="d-none d-sm-block"
|
||||
active={page === i + 1}
|
||||
onClick={() => onChange(i + 1)}
|
||||
>
|
||||
{i + 1}
|
||||
</PaginationItem>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{page < 4 ? (
|
||||
[1, 2, 3, 4].map((n) => (
|
||||
<PaginationItem
|
||||
key={n}
|
||||
className="d-none d-sm-block"
|
||||
active={page === n}
|
||||
onClick={() => onChange(n)}
|
||||
>
|
||||
{n}
|
||||
</PaginationItem>
|
||||
))
|
||||
) : (
|
||||
<PaginationItem
|
||||
className="d-none d-sm-block"
|
||||
onClick={() => onChange(1)}
|
||||
>
|
||||
1
|
||||
</PaginationItem>
|
||||
)}
|
||||
<PaginationItem className="d-none d-sm-block" disabled>
|
||||
...
|
||||
</PaginationItem>
|
||||
{page > 3 && page < totalPages - 2 && (
|
||||
<>
|
||||
{[page - 1, page, page + 1].map((n) => (
|
||||
<PaginationItem
|
||||
key={n}
|
||||
className="d-none d-sm-block"
|
||||
active={page === n}
|
||||
onClick={() => onChange(n)}
|
||||
>
|
||||
{n}
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem className="d-none d-sm-block" disabled>
|
||||
...
|
||||
</PaginationItem>
|
||||
</>
|
||||
)}
|
||||
{totalPages - page < 3 ? (
|
||||
[totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map(
|
||||
(n) => (
|
||||
<PaginationItem
|
||||
key={n}
|
||||
className="d-none d-sm-block"
|
||||
active={page === n}
|
||||
onClick={() => onChange(n)}
|
||||
>
|
||||
{n}
|
||||
</PaginationItem>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<PaginationItem
|
||||
className="d-none d-sm-block"
|
||||
onClick={() => onChange(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</PaginationItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<PaginationItem
|
||||
title={t('vendor.datatable.next')}
|
||||
disabled={page === totalPages}
|
||||
onClick={() => onChange(page + 1)}
|
||||
>
|
||||
<span className="d-inline d-sm-none mr-1">
|
||||
{t('vendor.datatable.next')}
|
||||
</span>
|
||||
{labels.next}
|
||||
</PaginationItem>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ul className='pagination'>
|
||||
<PaginationItem
|
||||
title={t('vendor.datatable.prev')}
|
||||
disabled={page === 1}
|
||||
onClick={async () => onChange(page - 1)}
|
||||
>
|
||||
{labels.prev}
|
||||
<span className='d-inline d-sm-none ml-1'>
|
||||
{t('vendor.datatable.prev')}
|
||||
</span>
|
||||
</PaginationItem>
|
||||
{totalPages < 8
|
||||
? Array.from({length: totalPages}).map((_, i) => (
|
||||
<PaginationItem
|
||||
key={i}
|
||||
className='d-none d-sm-block'
|
||||
active={page === i + 1}
|
||||
onClick={async () => onChange(i + 1)}
|
||||
>
|
||||
{i + 1}
|
||||
</PaginationItem>
|
||||
))
|
||||
: (
|
||||
<>
|
||||
{page < 4
|
||||
? [1, 2, 3, 4].map(n => (
|
||||
<PaginationItem
|
||||
key={n}
|
||||
className='d-none d-sm-block'
|
||||
active={page === n}
|
||||
onClick={async () => onChange(n)}
|
||||
>
|
||||
{n}
|
||||
</PaginationItem>
|
||||
))
|
||||
: (
|
||||
<PaginationItem
|
||||
className='d-none d-sm-block'
|
||||
onClick={async () => onChange(1)}
|
||||
>
|
||||
1
|
||||
</PaginationItem>
|
||||
)}
|
||||
<PaginationItem disabled className='d-none d-sm-block'>
|
||||
...
|
||||
</PaginationItem>
|
||||
{page > 3 && page < totalPages - 2 && (
|
||||
<>
|
||||
{[page - 1, page, page + 1].map(n => (
|
||||
<PaginationItem
|
||||
key={n}
|
||||
className='d-none d-sm-block'
|
||||
active={page === n}
|
||||
onClick={async () => onChange(n)}
|
||||
>
|
||||
{n}
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem disabled className='d-none d-sm-block'>
|
||||
...
|
||||
</PaginationItem>
|
||||
</>
|
||||
)}
|
||||
{totalPages - page < 3
|
||||
? [totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map(n => (
|
||||
<PaginationItem
|
||||
key={n}
|
||||
className='d-none d-sm-block'
|
||||
active={page === n}
|
||||
onClick={async () => onChange(n)}
|
||||
>
|
||||
{n}
|
||||
</PaginationItem>
|
||||
))
|
||||
: (
|
||||
<PaginationItem
|
||||
className='d-none d-sm-block'
|
||||
onClick={async () => onChange(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</PaginationItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<PaginationItem
|
||||
title={t('vendor.datatable.next')}
|
||||
disabled={page === totalPages}
|
||||
onClick={async () => onChange(page + 1)}
|
||||
>
|
||||
<span className='d-inline d-sm-none mr-1'>
|
||||
{t('vendor.datatable.next')}
|
||||
</span>
|
||||
{labels.next}
|
||||
</PaginationItem>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination
|
||||
export default Pagination;
|
||||
|
|
|
|||
|
|
@ -1,39 +1,41 @@
|
|||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
title?: string
|
||||
className?: string
|
||||
onClick?(): void
|
||||
}
|
||||
type Props = {
|
||||
readonly disabled?: boolean;
|
||||
readonly active?: boolean;
|
||||
readonly title?: string;
|
||||
readonly className?: string;
|
||||
onClick?: () => void;
|
||||
readonly children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const PaginationItem: React.FC<Props> = (props) => {
|
||||
const classes = ['page-item']
|
||||
if (props.active) {
|
||||
classes.push('active')
|
||||
}
|
||||
if (props.disabled) {
|
||||
classes.push('disabled')
|
||||
}
|
||||
if (props.className) {
|
||||
classes.push(props.className)
|
||||
}
|
||||
const PaginationItem: React.FC<Props> = props => {
|
||||
const classes = ['page-item'];
|
||||
if (props.active) {
|
||||
classes.push('active');
|
||||
}
|
||||
|
||||
const handleClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
if (!props.disabled && props.onClick) {
|
||||
props.onClick()
|
||||
}
|
||||
}
|
||||
if (props.disabled) {
|
||||
classes.push('disabled');
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={classes.join(' ')} title={props.title} onClick={handleClick}>
|
||||
<a href="#" className="page-link" aria-disabled={props.disabled}>
|
||||
{props.children}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
if (props.className) {
|
||||
classes.push(props.className);
|
||||
}
|
||||
|
||||
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 React, { useState, useEffect } from 'react'
|
||||
import { css } from '@emotion/react'
|
||||
import {css} from '@emotion/react';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
export type ToastType = 'success' | 'info' | 'warning' | 'error'
|
||||
export type ToastType = 'success' | 'info' | 'warning' | 'error';
|
||||
|
||||
interface Props {
|
||||
type: ToastType
|
||||
distance: number
|
||||
onClose(): void | Promise<void>
|
||||
}
|
||||
type Props = {
|
||||
readonly type: ToastType;
|
||||
readonly distance: number;
|
||||
onClose: () => void | Promise<void>;
|
||||
readonly children: React.ReactNode;
|
||||
};
|
||||
|
||||
const icons = new Map<ToastType, string>([
|
||||
['success', 'check'],
|
||||
['info', 'info'],
|
||||
['warning', 'exclamation-triangle'],
|
||||
['error', 'times-circle'],
|
||||
])
|
||||
['success', 'check'],
|
||||
['info', 'info'],
|
||||
['warning', 'exclamation-triangle'],
|
||||
['error', 'times-circle'],
|
||||
]);
|
||||
|
||||
const wrapper = css`
|
||||
position: fixed;
|
||||
|
|
@ -24,52 +24,54 @@ const wrapper = css`
|
|||
z-index: 1050;
|
||||
transition-property: top;
|
||||
transition-duration: 0.3s;
|
||||
`
|
||||
`;
|
||||
const shadow = css`
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
|
||||
`
|
||||
`;
|
||||
|
||||
const Toast: React.FC<Props> = (props) => {
|
||||
const [show, setShow] = useState(false)
|
||||
const Toast: React.FC<Props> = props => {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShow(true), 100)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setShow(true);
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [props.onClose])
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [props.onClose]);
|
||||
|
||||
const type = props.type === 'error' ? 'danger' : props.type
|
||||
const type = props.type === 'error' ? 'danger' : props.type;
|
||||
|
||||
const classes = [
|
||||
`alert alert-${type}`,
|
||||
'd-flex justify-content-between',
|
||||
'fade',
|
||||
]
|
||||
if (show) {
|
||||
classes.push('show')
|
||||
}
|
||||
const classes = [
|
||||
`alert alert-${type}`,
|
||||
'd-flex justify-content-between',
|
||||
'fade',
|
||||
];
|
||||
if (show) {
|
||||
classes.push('show');
|
||||
}
|
||||
|
||||
const role = type === 'success' || type === 'info' ? 'status' : 'alert'
|
||||
const role = type === 'success' || type === 'info' ? 'status' : 'alert';
|
||||
|
||||
return (
|
||||
<div css={wrapper} style={{ top: `${props.distance}px` }}>
|
||||
<div className={classes.join(' ')} css={shadow} role={role}>
|
||||
<span className="mr-1 d-flex align-items-center">
|
||||
<i className={`icon fas fa-${icons.get(props.type)}`}></i>
|
||||
</span>
|
||||
<span>{props.children}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-2 ml-1 close"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div css={wrapper} style={{top: `${props.distance}px`}}>
|
||||
<div className={classes.join(' ')} css={shadow} role={role}>
|
||||
<span className='mr-1 d-flex align-items-center'>
|
||||
<i className={`icon fas fa-${icons.get(props.type)}`}/>
|
||||
</span>
|
||||
<span>{props.children}</span>
|
||||
<button
|
||||
type='button'
|
||||
className='mr-2 ml-1 close'
|
||||
onClick={props.onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast
|
||||
export default Toast;
|
||||
|
|
|
|||
|
|
@ -1,38 +1,38 @@
|
|||
/** @jsxImportSource @emotion/react */
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useMeasure } from 'react-use'
|
||||
import { css } from '@emotion/react'
|
||||
import styled from '@emotion/styled'
|
||||
import * as skinview3d from 'skinview3d'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as cssUtils from '@/styles/utils'
|
||||
import * as breakpoints from '@/styles/breakpoints'
|
||||
import SkinSteve from '../../../misc/textures/steve.png'
|
||||
import bg1 from '../../../misc/backgrounds/1.webp'
|
||||
import bg2 from '../../../misc/backgrounds/2.webp'
|
||||
import bg3 from '../../../misc/backgrounds/3.webp'
|
||||
import bg4 from '../../../misc/backgrounds/4.webp'
|
||||
import bg5 from '../../../misc/backgrounds/5.webp'
|
||||
import bg6 from '../../../misc/backgrounds/6.webp'
|
||||
import bg7 from '../../../misc/backgrounds/7.webp'
|
||||
import {t} from '@/scripts/i18n';
|
||||
import * as breakpoints from '@/styles/breakpoints';
|
||||
import * as cssUtils from '@/styles/utils';
|
||||
import {css} from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {useMeasure} from 'react-use';
|
||||
import * as skinview3d from 'skinview3d';
|
||||
import bg1 from '../../../misc/backgrounds/1.webp';
|
||||
import bg2 from '../../../misc/backgrounds/2.webp';
|
||||
import bg3 from '../../../misc/backgrounds/3.webp';
|
||||
import bg4 from '../../../misc/backgrounds/4.webp';
|
||||
import bg5 from '../../../misc/backgrounds/5.webp';
|
||||
import bg6 from '../../../misc/backgrounds/6.webp';
|
||||
import bg7 from '../../../misc/backgrounds/7.webp';
|
||||
import SkinSteve from '../../../misc/textures/steve.png';
|
||||
|
||||
const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7]
|
||||
export const PICTURES_COUNT = backgrounds.length
|
||||
const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7];
|
||||
export const PICTURES_COUNT = backgrounds.length;
|
||||
|
||||
interface Props {
|
||||
skin?: string
|
||||
cape?: string
|
||||
isAlex: boolean
|
||||
showIndicator?: boolean
|
||||
initPositionZ?: number
|
||||
}
|
||||
type Props = {
|
||||
readonly skin?: string;
|
||||
readonly cape?: string;
|
||||
readonly children?: React.ReactNode;
|
||||
readonly isAlex: boolean;
|
||||
readonly showIndicator?: boolean;
|
||||
readonly initPositionZ?: number;
|
||||
};
|
||||
|
||||
const animationFactories = [
|
||||
() => new skinview3d.WalkingAnimation(),
|
||||
() => new skinview3d.RunningAnimation(),
|
||||
() => new skinview3d.FlyingAnimation(),
|
||||
() => new skinview3d.IdleAnimation(),
|
||||
]
|
||||
() => new skinview3d.WalkingAnimation(),
|
||||
() => new skinview3d.RunningAnimation(),
|
||||
() => new skinview3d.FlyingAnimation(),
|
||||
() => new skinview3d.IdleAnimation(),
|
||||
];
|
||||
|
||||
const ActionButton = styled.i`
|
||||
display: inline;
|
||||
|
|
@ -41,7 +41,7 @@ const ActionButton = styled.i`
|
|||
color: #555;
|
||||
cursor: pointer;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const cssViewer = css`
|
||||
flex: 1 1 auto;
|
||||
|
|
@ -56,251 +56,255 @@ const cssViewer = css`
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const Viewer: React.FC<Props> = (props) => {
|
||||
const { initPositionZ = 70 } = props
|
||||
const Viewer: React.FC<Props> = props => {
|
||||
const {initPositionZ = 70} = props;
|
||||
|
||||
const viewRef: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!)
|
||||
const containerRef = useRef<HTMLCanvasElement>(null)
|
||||
const viewReference: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!);
|
||||
const containerReference = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [animation, setAnimation] = useState(0)
|
||||
const [bgPicture, setBgPicture] = useState(-1)
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [animation, setAnimation] = useState(0);
|
||||
const [bgPicture, setBgPicture] = useState(-1);
|
||||
|
||||
const indicator = (() => {
|
||||
const { skin, cape } = props
|
||||
if (skin && cape) {
|
||||
return `${t('general.skin')} & ${t('general.cape')}`
|
||||
} else if (skin) {
|
||||
return t('general.skin')
|
||||
} else if (cape) {
|
||||
return t('general.cape')
|
||||
}
|
||||
return ''
|
||||
})()
|
||||
const indicator = (() => {
|
||||
const {skin, cape} = props;
|
||||
if (skin && cape) {
|
||||
return `${t('general.skin')} & ${t('general.cape')}`;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current!
|
||||
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 (skin) {
|
||||
return t('general.skin');
|
||||
}
|
||||
|
||||
if (document.body.classList.contains('dark-mode')) {
|
||||
viewer.background = '#6c757d'
|
||||
}
|
||||
if (cape) {
|
||||
return t('general.cape');
|
||||
}
|
||||
|
||||
viewRef.current = viewer
|
||||
return '';
|
||||
})();
|
||||
|
||||
return () => {
|
||||
viewer.dispose()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const container = containerReference.current!;
|
||||
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;
|
||||
|
||||
const [containerWrapperRef, containerMeasure] = useMeasure<HTMLDivElement>()
|
||||
useEffect(() => {
|
||||
viewRef.current.setSize(containerMeasure.width, containerMeasure.height)
|
||||
})
|
||||
if (document.body.classList.contains('dark-mode')) {
|
||||
viewer.background = '#6c757d';
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const viewer = viewRef.current
|
||||
viewer.loadSkin(props.skin || SkinSteve, {
|
||||
model: props.isAlex ? 'slim' : 'default',
|
||||
})
|
||||
}, [props.skin, props.isAlex])
|
||||
viewReference.current = viewer;
|
||||
|
||||
useEffect(() => {
|
||||
const viewer = viewRef.current
|
||||
if (props.cape) {
|
||||
viewer.loadCape(props.cape)
|
||||
} else {
|
||||
viewer.resetCape()
|
||||
}
|
||||
}, [props.cape])
|
||||
return () => {
|
||||
viewer.dispose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewer = viewRef.current
|
||||
const factory = animationFactories[animation]
|
||||
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])
|
||||
const [containerWrapperReference, containerMeasure] = useMeasure<HTMLDivElement>();
|
||||
useEffect(() => {
|
||||
viewReference.current.setSize(containerMeasure.width, containerMeasure.height);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const currentAnimation = viewRef.current.animation
|
||||
if (currentAnimation !== null) {
|
||||
currentAnimation.paused = paused
|
||||
}
|
||||
}, [paused])
|
||||
useEffect(() => {
|
||||
const viewer = viewReference.current;
|
||||
viewer.loadSkin(props.skin || SkinSteve, {
|
||||
model: props.isAlex ? 'slim' : 'default',
|
||||
});
|
||||
}, [props.skin, props.isAlex]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewer = viewRef.current
|
||||
const backgroundUrl = backgrounds[bgPicture]
|
||||
if (backgroundUrl === undefined) {
|
||||
viewer.background = null
|
||||
} else {
|
||||
viewer.loadBackground(backgroundUrl)
|
||||
}
|
||||
}, [bgPicture])
|
||||
useEffect(() => {
|
||||
const viewer = viewReference.current;
|
||||
if (props.cape) {
|
||||
viewer.loadCape(props.cape);
|
||||
} else {
|
||||
viewer.resetCape();
|
||||
}
|
||||
}, [props.cape]);
|
||||
|
||||
const togglePause = () => {
|
||||
setPaused((paused) => {
|
||||
if (paused) {
|
||||
return false
|
||||
} else {
|
||||
viewRef.current.autoRotate = false
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
const viewer = viewReference.current;
|
||||
const factory = animationFactories[animation];
|
||||
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]);
|
||||
|
||||
const toggleAnimation = () => {
|
||||
setAnimation((index) => (index + 1) % animationFactories.length)
|
||||
setPaused(false)
|
||||
}
|
||||
useEffect(() => {
|
||||
const currentAnimation = viewReference.current.animation;
|
||||
if (currentAnimation !== null) {
|
||||
currentAnimation.paused = paused;
|
||||
}
|
||||
}, [paused]);
|
||||
|
||||
const toggleRotate = () => {
|
||||
const viewer = viewRef.current
|
||||
viewer.autoRotate = !viewer.autoRotate
|
||||
}
|
||||
useEffect(() => {
|
||||
const viewer = viewReference.current;
|
||||
const backgroundUrl = backgrounds[bgPicture];
|
||||
if (backgroundUrl === undefined) {
|
||||
viewer.background = null;
|
||||
} else {
|
||||
viewer.loadBackground(backgroundUrl);
|
||||
}
|
||||
}, [bgPicture]);
|
||||
|
||||
const toggleBackEquippment = () => {
|
||||
const player = viewRef.current.playerObject
|
||||
if (player.backEquipment === 'cape') {
|
||||
player.backEquipment = 'elytra'
|
||||
} else {
|
||||
player.backEquipment = 'cape'
|
||||
}
|
||||
}
|
||||
const togglePause = () => {
|
||||
setPaused(paused => {
|
||||
if (paused) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const setWhite = () => {
|
||||
viewRef.current.background = '#fff'
|
||||
}
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
viewReference.current.autoRotate = false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
const toggleAnimation = () => {
|
||||
setAnimation(index => (index + 1) % animationFactories.length);
|
||||
setPaused(false);
|
||||
};
|
||||
|
||||
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 = () => (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex justify-content-between">
|
||||
<h3 className="card-title">
|
||||
<span>{t('general.texturePreview')}</span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body"></div>
|
||||
</div>
|
||||
)
|
||||
export default function ViewerSkeleton() {
|
||||
return (
|
||||
<div className='card'>
|
||||
<div className='card-header'>
|
||||
<div className='d-flex justify-content-between'>
|
||||
<h3 className='card-title'>
|
||||
<span>{t('general.texturePreview')}</span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className='card-body'/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewerSkeleton
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
@import '../fonts/minecraft.css';
|
||||
@import './avatar.css';
|
||||
@import '@/fonts/minecraft.css';
|
||||
@import '@/styles/avatar.css';
|
||||
|
||||
body {
|
||||
font-size: 16px;
|
||||
|
|
@ -1,37 +1,35 @@
|
|||
import * as React from 'react'
|
||||
import * as ReactDOM from 'react-dom'
|
||||
import $ from 'jquery'
|
||||
import './scripts/app'
|
||||
import routes from './scripts/route'
|
||||
import $ from 'jquery';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import routes from './scripts/route';
|
||||
|
||||
Object.assign(window, { React, ReactDOM, $ })
|
||||
import './scripts/app';
|
||||
|
||||
const entry = document.querySelector('[href="#launch-cli"]')
|
||||
entry?.addEventListener('click', async () => {
|
||||
const { launch } = await import('./scripts/cli')
|
||||
launch()
|
||||
})
|
||||
// eslint-disable-next-line ts/naming-convention
|
||||
Object.assign(window, {React, ReactDOM, $});
|
||||
|
||||
const route = routes.find((route) =>
|
||||
new RegExp(`^${route.path}$`, 'i').test(blessing.route),
|
||||
)
|
||||
const route = routes.find(route =>
|
||||
new RegExp(`^${route.path}$`, 'i').test(blessing.route));
|
||||
if (route) {
|
||||
if (route.module) {
|
||||
Promise.all(route.module.map((m) => m()))
|
||||
}
|
||||
if (route.react) {
|
||||
const Component = React.lazy(
|
||||
route.react as () => Promise<{ default: React.ComponentType }>,
|
||||
)
|
||||
const Root = () => (
|
||||
<React.StrictMode>
|
||||
<React.Suspense fallback={route.frame?.() ?? ''}>
|
||||
<Component />
|
||||
</React.Suspense>
|
||||
</React.StrictMode>
|
||||
)
|
||||
const c =
|
||||
typeof route.el === 'string' ? document.querySelector(route.el) : route.el
|
||||
ReactDOM.render(<Root />, c)
|
||||
}
|
||||
if (route.module) {
|
||||
void Promise.all(route.module.map(async m => m()));
|
||||
}
|
||||
|
||||
if (route.react) {
|
||||
const Component = React.lazy(route.react as () => Promise<{default: React.ComponentType}>);
|
||||
|
||||
const container = typeof route.el === 'string'
|
||||
? document.querySelector(route.el)
|
||||
: null;
|
||||
|
||||
const root = createRoot(container!);
|
||||
root.render((
|
||||
<React.StrictMode>
|
||||
<React.Suspense fallback={route.frame?.() ?? ''}>
|
||||
<Component/>
|
||||
</React.Suspense>
|
||||
</React.StrictMode>
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import './init' // must be first
|
||||
import 'admin-lte'
|
||||
import './extra'
|
||||
import './i18n'
|
||||
import './net'
|
||||
import './event'
|
||||
import './notification'
|
||||
import './emailVerification'
|
||||
import './logout'
|
||||
import './darkMode'
|
||||
import {Tooltip} from 'bootstrap';
|
||||
import '@popperjs/core';
|
||||
import 'admin-lte';
|
||||
import './extra';
|
||||
import './i18n';
|
||||
import './net';
|
||||
import './event';
|
||||
import './notification';
|
||||
import './emailVerification';
|
||||
import './logout';
|
||||
import './darkMode';
|
||||
|
||||
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 * as ReactDOM from 'react-dom'
|
||||
import DarkModeButton from '@/components/DarkModeButton'
|
||||
import DarkModeButton from '@/components/DarkModeButton';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
const el = document.querySelector('#toggle-dark-mode')
|
||||
if (el) {
|
||||
const initMode = document.body.classList.contains('dark-mode')
|
||||
ReactDOM.render(<DarkModeButton initMode={initMode} />, el)
|
||||
const element = document.querySelector('#toggle-dark-mode');
|
||||
if (element) {
|
||||
const initMode = document.body.classList.contains('dark-mode');
|
||||
ReactDOM.render(<DarkModeButton initMode={initMode}/>, element);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import EmailVerification from '@/views/widgets/EmailVerification'
|
||||
import EmailVerification from '@/views/widgets/EmailVerification';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
const container = document.querySelector('#email-verification')
|
||||
const container = document.querySelector('#email-verification');
|
||||
|
||||
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<CallableFunction>>()
|
||||
const bus = new Map<string | symbol, Set<(...args: any[]) => void>>();
|
||||
|
||||
export function on(event: string | symbol, listener: CallableFunction) {
|
||||
if (!bus.has(event)) {
|
||||
bus.set(event, new Set())
|
||||
}
|
||||
const listeners = bus.get(event)!
|
||||
listeners.add(listener)
|
||||
export function on(event: string | symbol, listener: (...args: any[]) => void) {
|
||||
if (!bus.has(event)) {
|
||||
bus.set(event, new Set());
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
const listeners = bus.get(event)!;
|
||||
listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
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> {
|
||||
const jsonElement = document.querySelector('#blessing-extra')
|
||||
/* istanbul ignore next */
|
||||
if (jsonElement) {
|
||||
return JSON.parse(jsonElement.textContent ?? '{}')
|
||||
} else {
|
||||
return {}
|
||||
}
|
||||
const jsonElement = document.querySelector('#blessing-extra');
|
||||
|
||||
if (jsonElement) {
|
||||
return JSON.parse(jsonElement.textContent ?? '{}');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
blessing.extra = getExtraData()
|
||||
blessing.extra = getExtraData();
|
||||
|
|
|
|||
|
|
@ -1,25 +1,24 @@
|
|||
import { getExtraData } from './extra'
|
||||
import {getExtraData} from './extra';
|
||||
|
||||
export function scrollHander() {
|
||||
const header = document.querySelector('.navbar')
|
||||
/* istanbul ignore else */
|
||||
if (header) {
|
||||
window.addEventListener('scroll', () => {
|
||||
if (window.scrollY >= (window.innerHeight * 2) / 3) {
|
||||
header.classList.remove('transparent')
|
||||
} else {
|
||||
header.classList.add('transparent')
|
||||
}
|
||||
})
|
||||
}
|
||||
const header = document.querySelector('.navbar');
|
||||
/* istanbul ignore else */
|
||||
if (header) {
|
||||
window.addEventListener('scroll', () => {
|
||||
if (window.scrollY >= (window.innerHeight * 2) / 3) {
|
||||
header.classList.remove('transparent');
|
||||
} else {
|
||||
header.classList.add('transparent');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const { transparent_navbar } = getExtraData() as {
|
||||
transparent_navbar: boolean
|
||||
}
|
||||
if (transparent_navbar) {
|
||||
window.addEventListener('load', scrollHander)
|
||||
}
|
||||
const {transparent_navbar} = getExtraData() as {
|
||||
transparent_navbar: boolean;
|
||||
};
|
||||
if (transparent_navbar) {
|
||||
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 {
|
||||
const [value, setValue] = useState<T>(defaultValue!)
|
||||
const [value, setValue] = useState<T>(defaultValue!);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(blessing.extra[key] as T)
|
||||
}, [key])
|
||||
useEffect(() => {
|
||||
setValue(blessing.extra[key] as T);
|
||||
}, [key]);
|
||||
|
||||
return value
|
||||
return value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect } from 'react'
|
||||
import { emit } from '../event'
|
||||
import {useEffect} from 'react';
|
||||
import {emit} from '../event';
|
||||
|
||||
export default function useEmitMounted() {
|
||||
useEffect(() => {
|
||||
emit('mounted')
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
emit('mounted');
|
||||
}, []);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
export default function useIsLargeScreen() {
|
||||
const [isLarge, setIsLarge] = useState(false)
|
||||
const [isLarge, setIsLarge] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth >= 992) {
|
||||
setIsLarge(true)
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (window.innerWidth >= 992) {
|
||||
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 {
|
||||
const container = useRef<HTMLDivElement | null>(null)
|
||||
export default function useMount(selector: string): HTMLElement | undefined {
|
||||
const container = useRef<HTMLDivElement | undefined>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mount = document.querySelector(selector)!
|
||||
const div = document.createElement('div')
|
||||
container.current = div
|
||||
useEffect(() => {
|
||||
const mount = document.querySelector(selector)!;
|
||||
const div = document.createElement('div');
|
||||
container.current = div;
|
||||
|
||||
mount.appendChild(div)
|
||||
mount.append(div);
|
||||
|
||||
return () => {
|
||||
mount.removeChild(div)
|
||||
container.current = null
|
||||
}
|
||||
}, [selector])
|
||||
return () => {
|
||||
div.remove();
|
||||
container.current = null;
|
||||
};
|
||||
}, [selector]);
|
||||
|
||||
return container.current
|
||||
return container.current;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,27 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import * as fetch from '../net'
|
||||
import { Texture, TextureType } from '../types'
|
||||
import {useEffect, useState} from 'react';
|
||||
import * as fetch from '../net';
|
||||
import {type Texture, TextureType} from '../types';
|
||||
|
||||
export default function useTexture() {
|
||||
const [tid, setTid] = useState(0)
|
||||
const [url, setUrl] = useState('')
|
||||
const [type, setType] = useState(TextureType.Steve)
|
||||
const [tid, setTid] = useState(0);
|
||||
const [url, setUrl] = useState('');
|
||||
const [type, setType] = useState(TextureType.Steve);
|
||||
|
||||
useEffect(() => {
|
||||
if (tid <= 0) {
|
||||
setUrl('')
|
||||
return
|
||||
}
|
||||
useEffect(() => {
|
||||
if (tid <= 0) {
|
||||
setUrl('');
|
||||
return;
|
||||
}
|
||||
|
||||
const getTexture = async () => {
|
||||
const { hash, type } = await fetch.get<Texture>(`/skinlib/info/${tid}`)
|
||||
const getTexture = async () => {
|
||||
const {hash, type} = await fetch.get<Texture>(`/skinlib/info/${tid}`);
|
||||
|
||||
setUrl(`${blessing.base_url}/textures/${hash}`)
|
||||
setType(type)
|
||||
}
|
||||
getTexture()
|
||||
}, [tid])
|
||||
setUrl(`${blessing.base_url}/textures/${hash}`);
|
||||
setType(type);
|
||||
};
|
||||
|
||||
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) {
|
||||
const [value, setValue] = useState<T>(initialValue)
|
||||
const ref = useRef<T>(value)
|
||||
const [dest, setDest] = useState<T>(initialValue)
|
||||
const [value, setValue] = useState<T>(initialValue);
|
||||
const reference = useRef<T>(value);
|
||||
const [destination, setDestination] = useState<T>(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
function animate() {
|
||||
requestAnimationFrame(animate)
|
||||
TWEEN.update()
|
||||
setValue(ref.current)
|
||||
}
|
||||
useEffect(() => {
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
TWEEN.update();
|
||||
setValue(reference.current);
|
||||
}
|
||||
|
||||
const tween = new TWEEN.Tween(ref)
|
||||
tween.to({ current: dest }, 1000).start()
|
||||
animate()
|
||||
}, [dest])
|
||||
const tween = new TWEEN.Tween(reference);
|
||||
tween.to({current: destination}, 1000).start();
|
||||
animate();
|
||||
}, [destination]);
|
||||
|
||||
return [value, setDest] as const
|
||||
return [value, setDestination] as const;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,34 @@
|
|||
interface I18nTable {
|
||||
[key: string]: string | I18nTable | undefined
|
||||
type I18nTable = {
|
||||
[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 {
|
||||
const segments = key.split('.')
|
||||
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 })
|
||||
Object.assign(window, {trans: t});
|
||||
Object.assign(blessing, {t});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
declare let __webpack_public_path__: string
|
||||
declare const __blessing_public_path__: string
|
||||
declare let __webpack_public_path__: string;
|
||||
declare const __blessing_public_path__: string;
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
__webpack_public_path__ = __blessing_public_path__
|
||||
if (import.meta.env.NODE_ENV === 'development') {
|
||||
__webpack_public_path__ = __blessing_public_path__;
|
||||
} else {
|
||||
const link = document.querySelector<HTMLLinkElement>('link#cdn-host')
|
||||
const base = link?.href ?? blessing.base_url
|
||||
__webpack_public_path__ = `${base}/app/`
|
||||
const link = document.querySelector<HTMLLinkElement>('link#cdn-host');
|
||||
const base = link?.href ?? blessing.base_url;
|
||||
__webpack_public_path__ = `${base}/app/`;
|
||||
}
|
||||
|
||||
export {}
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import { post } from './net'
|
||||
import { t } from './i18n'
|
||||
import { showModal } from './notify'
|
||||
import urls from './urls'
|
||||
import {t} from './i18n';
|
||||
import {post} from './net';
|
||||
import {showModal} from './notify';
|
||||
import urls from './urls';
|
||||
|
||||
export async function logout() {
|
||||
try {
|
||||
await showModal({
|
||||
text: t('general.confirmLogout'),
|
||||
center: true,
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await showModal({
|
||||
text: t('general.confirmLogout'),
|
||||
center: true,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await post(urls.auth.logout())
|
||||
window.location.href = blessing.base_url
|
||||
await post(urls.auth.logout());
|
||||
window.location.href = blessing.base_url;
|
||||
}
|
||||
|
||||
const button = document.querySelector('#logout-button')
|
||||
/* istanbul ignore next */
|
||||
button?.addEventListener('click', logout)
|
||||
const button = document.querySelector('#logout-button');
|
||||
|
||||
button?.addEventListener('click', logout);
|
||||
|
|
|
|||
|
|
@ -1,27 +1,26 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import Modal, { ModalOptions, ModalResult } from '../components/Modal'
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import Modal, {type ModalOptions, type ModalResult} from '../components/Modal';
|
||||
|
||||
export function showModal(options: ModalOptions = {}): Promise<ModalResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
export async function showModal(options: ModalOptions = {}): Promise<ModalResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const handleClose = () => {
|
||||
ReactDOM.unmountComponentAtNode(container)
|
||||
document.body.removeChild(container)
|
||||
}
|
||||
const handleClose = () => {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
};
|
||||
|
||||
ReactDOM.render(
|
||||
<Modal
|
||||
{...options}
|
||||
show
|
||||
center
|
||||
onConfirm={resolve}
|
||||
onDismiss={reject}
|
||||
onClose={handleClose}
|
||||
/>,
|
||||
container,
|
||||
)
|
||||
})
|
||||
root.render((
|
||||
<Modal
|
||||
{...options}
|
||||
show
|
||||
center
|
||||
onConfirm={resolve}
|
||||
onDismiss={reject}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,159 +1,162 @@
|
|||
import { emit } from './event'
|
||||
import { showModal } from './notify'
|
||||
import { t } from './i18n'
|
||||
import {emit} from './event';
|
||||
import {t} from './i18n';
|
||||
import {showModal} from './notify';
|
||||
|
||||
export interface ResponseBody<T = null> {
|
||||
code: number
|
||||
message: string
|
||||
data: T extends null ? never : T
|
||||
}
|
||||
export type ResponseBody<T = undefined> = {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T extends undefined ? never : T;
|
||||
};
|
||||
|
||||
class HTTPError extends Error {
|
||||
response: Response
|
||||
response: Response;
|
||||
|
||||
constructor(message: string, response: Response) {
|
||||
super(message)
|
||||
this.response = response
|
||||
}
|
||||
constructor(message: string, response: Response) {
|
||||
super(message);
|
||||
this.response = response;
|
||||
}
|
||||
}
|
||||
|
||||
const empty = Object.create(null)
|
||||
const empty: Record<string, never> = Object.create(null);
|
||||
export const init: RequestInit = {
|
||||
credentials: 'same-origin',
|
||||
headers: new Headers({
|
||||
Accept: 'application/json',
|
||||
}),
|
||||
}
|
||||
credentials: 'same-origin',
|
||||
headers: new Headers({
|
||||
Accept: 'application/json',
|
||||
}),
|
||||
};
|
||||
|
||||
function retrieveToken() {
|
||||
const csrfField = document.querySelector<HTMLMetaElement>(
|
||||
'meta[name="csrf-token"]',
|
||||
)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
return csrfField?.content || ''
|
||||
const csrfField = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]');
|
||||
|
||||
return csrfField?.content || '';
|
||||
}
|
||||
|
||||
export async function walkFetch(request: Request): Promise<any> {
|
||||
request.headers.set('X-CSRF-TOKEN', retrieveToken())
|
||||
request.headers.set('X-CSRF-TOKEN', retrieveToken());
|
||||
|
||||
try {
|
||||
const response = await fetch(request)
|
||||
const cloned = response.clone()
|
||||
const body =
|
||||
response.headers.get('Content-Type') === 'application/json'
|
||||
? await response.json()
|
||||
: await response.text()
|
||||
if (response.ok) {
|
||||
return body
|
||||
}
|
||||
let message: string = body.message
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
const cloned = response.clone();
|
||||
const body
|
||||
= response.headers.get('Content-Type') === 'application/json'
|
||||
? await response.json()
|
||||
: await response.text();
|
||||
if (response.ok) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
// 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',
|
||||
})
|
||||
}
|
||||
let {message} = body;
|
||||
|
||||
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>`
|
||||
}
|
||||
if (response.status === 422) {
|
||||
// Process validation errors from Laravel.
|
||||
const {
|
||||
errors,
|
||||
}: {
|
||||
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)
|
||||
} catch (error: any) {
|
||||
emit('fetchError', error)
|
||||
await showModal({
|
||||
mode: 'alert',
|
||||
title: t('general.fatalError'),
|
||||
dangerousHTML: error.message,
|
||||
type: 'danger',
|
||||
okButtonType: 'outline-light',
|
||||
})
|
||||
if (response.status === 419) {
|
||||
return await showModal({
|
||||
mode: 'alert',
|
||||
text: t('general.csrf'),
|
||||
});
|
||||
}
|
||||
|
||||
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> {
|
||||
emit('beforeFetch', {
|
||||
method: 'GET',
|
||||
url,
|
||||
data: params,
|
||||
})
|
||||
export async function get<T = any>(url: string, parameters: Record<string, string> | URLSearchParams = empty): Promise<T> {
|
||||
emit('beforeFetch', {
|
||||
method: 'GET',
|
||||
url,
|
||||
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>(
|
||||
method: string,
|
||||
url: string,
|
||||
data?: FormData | Record<string, unknown>,
|
||||
async function nonGet<T = any>(
|
||||
method: string,
|
||||
url: string,
|
||||
data?: FormData | Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
emit('beforeFetch', {
|
||||
method: method.toUpperCase(),
|
||||
url,
|
||||
data,
|
||||
})
|
||||
emit('beforeFetch', {
|
||||
method: method.toUpperCase(),
|
||||
url,
|
||||
data,
|
||||
});
|
||||
|
||||
const request = new Request(`${blessing.base_url}${url}`, {
|
||||
body: data instanceof FormData ? data : JSON.stringify(data),
|
||||
method: method.toUpperCase(),
|
||||
...init,
|
||||
})
|
||||
if (!(data instanceof FormData)) {
|
||||
request.headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
const request = new Request(`${blessing.base_url}${url}`, {
|
||||
body: data instanceof FormData ? data : JSON.stringify(data),
|
||||
method: method.toUpperCase(),
|
||||
...init,
|
||||
});
|
||||
if (!(data instanceof FormData)) {
|
||||
request.headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
return walkFetch(request)
|
||||
return walkFetch(request);
|
||||
}
|
||||
|
||||
export function post<T = any>(
|
||||
url: string,
|
||||
data?: FormData | Record<string, unknown>,
|
||||
export async function post<T = any>(
|
||||
url: string,
|
||||
data?: FormData | Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
return nonGet<T>('POST', url, data)
|
||||
return nonGet<T>('POST', url, data);
|
||||
}
|
||||
|
||||
export function put<T = any>(
|
||||
url: string,
|
||||
data?: FormData | Record<string, unknown>,
|
||||
export async function put<T = any>(
|
||||
url: string,
|
||||
data?: FormData | Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
return nonGet<T>('PUT', url, data)
|
||||
return nonGet<T>('PUT', url, data);
|
||||
}
|
||||
|
||||
export function del<T = any>(
|
||||
url: string,
|
||||
data?: FormData | Record<string, unknown>,
|
||||
export async function del<T = any>(
|
||||
url: string,
|
||||
data?: FormData | Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
return nonGet<T>('DELETE', url, data)
|
||||
return nonGet<T>('DELETE', url, data);
|
||||
}
|
||||
|
||||
blessing.fetch = {
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
del,
|
||||
}
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
del,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import NotificationsList from '@/views/widgets/NotificationsList'
|
||||
import NotificationsList from '@/views/widgets/NotificationsList';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
|
||||
const container = document.querySelector('[data-notifications]')
|
||||
const container = document.querySelector('[data-notifications]');
|
||||
if (container) {
|
||||
ReactDOM.render(<NotificationsList />, container)
|
||||
createRoot(container).render(<NotificationsList/>);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import { showModal } from './modal'
|
||||
import { Toast } from './toast'
|
||||
import {showModal} from './modal';
|
||||
import {Toast} from './toast';
|
||||
|
||||
export const toast = new Toast()
|
||||
export const toast = new Toast();
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
afterEach(() => {
|
||||
toast.clear()
|
||||
})
|
||||
if (import.meta.env.NODE_ENV === 'test') {
|
||||
afterEach(() => {
|
||||
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 [
|
||||
{
|
||||
path: 'user',
|
||||
react: () => import('../views/user/Dashboard'),
|
||||
el: '#usage-box',
|
||||
frame: () => (
|
||||
<div className="card card-primary card-outline">
|
||||
<div className="card-header"> </div>
|
||||
<div className="card-body"></div>
|
||||
<div className="card-footer"> </div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'user/closet',
|
||||
react: () => import('../views/user/Closet'),
|
||||
el: '#closet-list',
|
||||
},
|
||||
{
|
||||
path: 'user/player',
|
||||
react: () => import('../views/user/Players'),
|
||||
el: '#players-list',
|
||||
frame: () => (
|
||||
<div className="card">
|
||||
<div className="card-header"> </div>
|
||||
<div className="card-body p-0"></div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'user/profile',
|
||||
module: [() => import('../views/user/profile/index')],
|
||||
},
|
||||
{
|
||||
path: 'user/oauth/manage',
|
||||
react: () => import('../views/user/OAuth'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
module: [() => import('../views/admin/Dashboard')],
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
react: () => import('../views/admin/UsersManagement'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
path: 'admin/players',
|
||||
react: () => import('../views/admin/PlayersManagement'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
path: 'admin/reports',
|
||||
react: () => import('../views/admin/ReportsManagement'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
path: 'admin/customize',
|
||||
module: [() => import('../views/admin/Customization')],
|
||||
},
|
||||
{
|
||||
path: 'admin/i18n',
|
||||
react: () => import('../views/admin/Translations'),
|
||||
el: '#table',
|
||||
},
|
||||
{
|
||||
path: 'admin/plugins/manage',
|
||||
react: () => import('../views/admin/PluginsManagement'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
path: 'admin/plugins/market',
|
||||
react: () => import('../views/admin/PluginsMarket'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
path: 'admin/update',
|
||||
module: [() => import('../views/admin/Update')],
|
||||
},
|
||||
{
|
||||
path: 'auth/login',
|
||||
react: () => import('../views/auth/Login'),
|
||||
el: 'main',
|
||||
},
|
||||
{
|
||||
path: 'auth/register',
|
||||
react: () => import('../views/auth/Registration'),
|
||||
el: 'main',
|
||||
},
|
||||
{
|
||||
path: 'auth/forgot',
|
||||
react: () => import('../views/auth/Forgot'),
|
||||
el: 'main',
|
||||
},
|
||||
{
|
||||
path: 'auth/reset/(\\d+)',
|
||||
react: () => import('../views/auth/Reset'),
|
||||
el: 'main',
|
||||
},
|
||||
{
|
||||
path: 'skinlib',
|
||||
react: () => import('../views/skinlib/SkinLibrary'),
|
||||
el: '.content-wrapper',
|
||||
},
|
||||
{
|
||||
path: 'skinlib/show/(\\d+)',
|
||||
react: () => import('../views/skinlib/Show'),
|
||||
el: '#side',
|
||||
},
|
||||
{
|
||||
path: 'skinlib/upload',
|
||||
react: () => import('../views/skinlib/Upload'),
|
||||
el: '#file-input',
|
||||
},
|
||||
]
|
||||
{
|
||||
path: 'user',
|
||||
react: async () => import('../views/user/Dashboard'),
|
||||
el: '#usage-box',
|
||||
frame: () => (
|
||||
<div className='card card-primary card-outline'>
|
||||
<div className='card-header'> </div>
|
||||
<div className='card-body'/>
|
||||
<div className='card-footer'> </div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'user/closet',
|
||||
react: async () => import('../views/user/Closet'),
|
||||
el: '#closet-list',
|
||||
},
|
||||
{
|
||||
path: 'user/player',
|
||||
react: async () => import('../views/user/Players'),
|
||||
el: '#players-list',
|
||||
frame: () => (
|
||||
<div className='card'>
|
||||
<div className='card-header'> </div>
|
||||
<div className='card-body p-0'/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'user/profile',
|
||||
module: [async () => import('../views/user/profile/index')],
|
||||
},
|
||||
{
|
||||
path: 'user/oauth/manage',
|
||||
react: async () => import('../views/user/OAuth'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
module: [async () => import('../views/admin/Dashboard')],
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
react: async () => import('../views/admin/UsersManagement'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
path: 'admin/players',
|
||||
react: async () => import('../views/admin/PlayersManagement'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
path: 'admin/reports',
|
||||
react: async () => import('../views/admin/ReportsManagement'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
path: 'admin/customize',
|
||||
module: [async () => import('../views/admin/Customization')],
|
||||
},
|
||||
{
|
||||
path: 'admin/i18n',
|
||||
react: async () => import('../views/admin/Translations'),
|
||||
el: '#table',
|
||||
},
|
||||
{
|
||||
path: 'admin/plugins/manage',
|
||||
react: async () => import('../views/admin/PluginsManagement'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
path: 'admin/plugins/market',
|
||||
react: async () => import('../views/admin/PluginsMarket'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
path: 'admin/update',
|
||||
module: [async () => import('../views/admin/Update')],
|
||||
},
|
||||
{
|
||||
path: 'auth/login',
|
||||
react: async () => import('../views/auth/Login'),
|
||||
el: 'main',
|
||||
},
|
||||
{
|
||||
path: 'auth/register',
|
||||
react: async () => import('../views/auth/Registration'),
|
||||
el: 'main',
|
||||
},
|
||||
{
|
||||
path: 'auth/forgot',
|
||||
react: async () => import('../views/auth/Forgot'),
|
||||
el: 'main',
|
||||
},
|
||||
{
|
||||
path: 'auth/reset/(\\d+)',
|
||||
react: async () => import('../views/auth/Reset'),
|
||||
el: 'main',
|
||||
},
|
||||
{
|
||||
path: 'skinlib',
|
||||
react: async () => import('../views/skinlib/SkinLibrary'),
|
||||
el: '.content-wrapper',
|
||||
},
|
||||
{
|
||||
path: 'skinlib/show/(\\d+)',
|
||||
react: async () => import('../views/skinlib/Show'),
|
||||
el: '#side',
|
||||
},
|
||||
{
|
||||
path: 'skinlib/upload',
|
||||
react: async () => import('../views/skinlib/Upload'),
|
||||
el: '#file-input',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,51 +1,49 @@
|
|||
import { loadSkinToCanvas } from 'skinview-utils'
|
||||
import {loadSkinToCanvas} from 'skinview-utils';
|
||||
|
||||
/* istanbul ignore next */
|
||||
function checkPixel(
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
const imageData = context.getImageData(x, y, 1, 1)
|
||||
const imageData = context.getImageData(x, y, 1, 1);
|
||||
|
||||
return (
|
||||
imageData.data[0] === 0 &&
|
||||
imageData.data[1] === 0 &&
|
||||
imageData.data[2] === 0
|
||||
)
|
||||
return (
|
||||
imageData.data[0] === 0
|
||||
&& imageData.data[1] === 0
|
||||
&& imageData.data[2] === 0
|
||||
);
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
export function isAlex(texture: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const image = new Image()
|
||||
image.src = texture
|
||||
image.onload = () => {
|
||||
if (image.width !== image.height) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
export async function isAlex(texture: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const image = new Image();
|
||||
image.src = texture;
|
||||
image.addEventListener('load', () => {
|
||||
if (image.width !== image.height) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
loadSkinToCanvas(canvas, image)
|
||||
const canvas = document.createElement('canvas');
|
||||
loadSkinToCanvas(canvas, image);
|
||||
|
||||
const ratio = canvas.width / 64
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
const ratio = canvas.width / 64;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let x = 46 * ratio; x < 48 * ratio; x += 1) {
|
||||
for (let y = 52 * ratio; y < 64 * ratio; y += 1) {
|
||||
if (!checkPixel(context, x, y)) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let x = 46 * ratio; x < 48 * ratio; x += 1) {
|
||||
for (let y = 52 * ratio; y < 64 * ratio; y += 1) {
|
||||
if (!checkPixel(context, x, y)) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,93 +1,101 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { nanoid } from 'nanoid'
|
||||
import * as emitter from './event'
|
||||
import ToastBox, { ToastType } from '../components/Toast'
|
||||
import {nanoid} from 'nanoid';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import ToastBox, {type ToastType} from '../components/Toast';
|
||||
import * as emitter from './event';
|
||||
|
||||
type QueueElement = { id: string; type: ToastType; message: string }
|
||||
type ToastQueue = QueueElement[]
|
||||
type QueueElement = {id: string; type: ToastType; message: string};
|
||||
type ToastQueue = QueueElement[];
|
||||
|
||||
const TOAST_EVENT = Symbol('toast')
|
||||
const CLEAR_EVENT = Symbol('clear')
|
||||
const ToastEvent = Symbol('toast');
|
||||
const ClearEvent = Symbol('clear');
|
||||
|
||||
export const ToastContainer: React.FC = () => {
|
||||
const [queue, setQueue] = useState<ToastQueue>([])
|
||||
export function ToastContainer() {
|
||||
const [queue, setQueue] = useState<ToastQueue>([]);
|
||||
|
||||
const handleClose = (id: string) => {
|
||||
setQueue((queue) => queue.filter((el) => el.id !== id))
|
||||
}
|
||||
const handleClose = (id: string) => {
|
||||
setQueue(queue => queue.filter(element => element.id !== id));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const off1 = emitter.on(TOAST_EVENT, (toast: QueueElement) => {
|
||||
setQueue((queue) => {
|
||||
queue.push(toast)
|
||||
return queue.slice()
|
||||
})
|
||||
useEffect(() => {
|
||||
const off1 = emitter.on(ToastEvent, (toast: QueueElement) => {
|
||||
setQueue(queue => {
|
||||
queue.push(toast);
|
||||
return [...queue];
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
handleClose(toast.id)
|
||||
}, 3100)
|
||||
})
|
||||
const off2 = emitter.on(CLEAR_EVENT, () => setQueue([]))
|
||||
// Effect dependency is empty
|
||||
// eslint-disable-next-line react-web-api/no-leaked-timeout
|
||||
setTimeout(() => {
|
||||
handleClose(toast.id);
|
||||
}, 3100);
|
||||
});
|
||||
const off2 = emitter.on(ClearEvent, () => {
|
||||
setQueue([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
off1()
|
||||
off2()
|
||||
}
|
||||
}, [])
|
||||
return () => {
|
||||
off1();
|
||||
off2();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{queue.map((el, i) => (
|
||||
<ToastBox
|
||||
key={el.id}
|
||||
type={el.type}
|
||||
distance={50 + i * 70}
|
||||
onClose={() => handleClose(el.id)}
|
||||
>
|
||||
{el.message}
|
||||
</ToastBox>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{queue.map((element, i) => (
|
||||
<ToastBox
|
||||
key={element.id}
|
||||
type={element.type}
|
||||
distance={50 + (i * 70)}
|
||||
onClose={() => {
|
||||
handleClose(element.id);
|
||||
}}
|
||||
>
|
||||
{element.message}
|
||||
</ToastBox>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export class Toast {
|
||||
private container: HTMLDivElement
|
||||
private readonly container: HTMLDivElement;
|
||||
private readonly root: Root;
|
||||
|
||||
constructor(render?: (element: JSX.Element) => void) {
|
||||
this.container = document.createElement('div')
|
||||
document.body.appendChild(this.container)
|
||||
constructor(render?: (element: React.JSX.Element) => void) {
|
||||
this.container = document.createElement('div');
|
||||
document.body.append(this.container);
|
||||
this.root = createRoot(this.container);
|
||||
|
||||
if (render) {
|
||||
render(<ToastContainer />)
|
||||
} else {
|
||||
ReactDOM.render(<ToastContainer />, this.container)
|
||||
}
|
||||
}
|
||||
if (render) {
|
||||
render(<ToastContainer/>);
|
||||
} else {
|
||||
this.root.render(<ToastContainer/>);
|
||||
}
|
||||
}
|
||||
|
||||
success(message: string) {
|
||||
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'success', message })
|
||||
}
|
||||
success(message: string) {
|
||||
emitter.emit(ToastEvent, {id: nanoid(4), type: 'success', message});
|
||||
}
|
||||
|
||||
info(message: string) {
|
||||
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'info', message })
|
||||
}
|
||||
info(message: string) {
|
||||
emitter.emit(ToastEvent, {id: nanoid(4), type: 'info', message});
|
||||
}
|
||||
|
||||
warning(message: string) {
|
||||
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'warning', message })
|
||||
}
|
||||
warning(message: string) {
|
||||
emitter.emit(ToastEvent, {id: nanoid(4), type: 'warning', message});
|
||||
}
|
||||
|
||||
error(message: string) {
|
||||
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'error', message })
|
||||
}
|
||||
error(message: string) {
|
||||
emitter.emit(ToastEvent, {id: nanoid(4), type: 'error', message});
|
||||
}
|
||||
|
||||
clear() {
|
||||
emitter.emit(CLEAR_EVENT)
|
||||
}
|
||||
clear() {
|
||||
emitter.emit(ClearEvent);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
ReactDOM.unmountComponentAtNode(this.container)
|
||||
this.container.remove()
|
||||
}
|
||||
dispose() {
|
||||
this.root.unmount();
|
||||
this.container.remove();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,61 @@
|
|||
export type User = {
|
||||
uid: number
|
||||
email: string
|
||||
nickname: string
|
||||
locale: string | null
|
||||
score: number
|
||||
avatar: number
|
||||
permission: UserPermission
|
||||
ip: string
|
||||
is_dark_mode: boolean
|
||||
last_sign_at: string
|
||||
register_at: string
|
||||
verified: boolean
|
||||
}
|
||||
uid: number;
|
||||
email: string;
|
||||
nickname: string;
|
||||
locale: string | undefined;
|
||||
score: number;
|
||||
avatar: number;
|
||||
permission: UserPermission;
|
||||
ip: string;
|
||||
is_dark_mode: boolean;
|
||||
last_sign_at: string;
|
||||
register_at: string;
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
export const enum UserPermission {
|
||||
Banned = -1,
|
||||
Normal = 0,
|
||||
Admin = 1,
|
||||
SuperAdmin = 2,
|
||||
Banned = -1,
|
||||
Normal = 0,
|
||||
Admin = 1,
|
||||
SuperAdmin = 2,
|
||||
}
|
||||
|
||||
export type Player = {
|
||||
pid: number
|
||||
name: string
|
||||
uid: number
|
||||
tid_skin: number
|
||||
tid_cape: number
|
||||
last_modified: string
|
||||
}
|
||||
pid: number;
|
||||
name: string;
|
||||
uid: number;
|
||||
tid_skin: number;
|
||||
tid_cape: number;
|
||||
last_modified: string;
|
||||
};
|
||||
|
||||
export type Texture = {
|
||||
tid: number
|
||||
name: string
|
||||
type: TextureType
|
||||
hash: string
|
||||
size: number
|
||||
uploader: number
|
||||
public: boolean
|
||||
upload_at: string
|
||||
likes: number
|
||||
}
|
||||
tid: number;
|
||||
name: string;
|
||||
type: TextureType;
|
||||
hash: string;
|
||||
size: number;
|
||||
uploader: number;
|
||||
public: boolean;
|
||||
upload_at: string;
|
||||
likes: number;
|
||||
};
|
||||
|
||||
export const enum TextureType {
|
||||
Steve = 'steve',
|
||||
Alex = 'alex',
|
||||
Cape = 'cape',
|
||||
Steve = 'steve',
|
||||
Alex = 'alex',
|
||||
Cape = 'cape',
|
||||
}
|
||||
|
||||
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> = {
|
||||
data: T[]
|
||||
current_page: number
|
||||
last_page: number
|
||||
from: number
|
||||
to: number
|
||||
total: number
|
||||
}
|
||||
data: T[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
from: number;
|
||||
to: number;
|
||||
total: number;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,68 +1,68 @@
|
|||
export default {
|
||||
admin: {
|
||||
players: {
|
||||
delete: (player: number) => `/admin/players/${player}`,
|
||||
list: () => '/admin/players/list' as const,
|
||||
name: (player: number) => `/admin/players/${player}/name`,
|
||||
owner: (player: number) => `/admin/players/${player}/owner`,
|
||||
texture: (player: number) => `/admin/players/${player}/textures`,
|
||||
},
|
||||
users: {
|
||||
delete: (user: number) => `/admin/users/${user}`,
|
||||
email: (user: number) => `/admin/users/${user}/email`,
|
||||
list: () => '/admin/users/list' as const,
|
||||
nickname: (user: number) => `/admin/users/${user}/nickname`,
|
||||
password: (user: number) => `/admin/users/${user}/password`,
|
||||
permission: (user: number) => `/admin/users/${user}/permission`,
|
||||
score: (user: number) => `/admin/users/${user}/score`,
|
||||
verification: (user: number) => `/admin/users/${user}/verification`,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
bind: () => '/auth/bind' as const,
|
||||
forgot: () => '/auth/forgot' as const,
|
||||
login: () => '/auth/login' as const,
|
||||
logout: () => '/auth/logout' as const,
|
||||
register: () => '/auth/register' as const,
|
||||
reset: (uid: number) => `/auth/reset/${uid}`,
|
||||
verify: (uid: number) => `/auth/verify/${uid}`,
|
||||
},
|
||||
skinlib: {
|
||||
home: () => '/skinlib' as const,
|
||||
info: (texture: number) => `/skinlib/info/${texture}`,
|
||||
list: () => '/skinlib/list' as const,
|
||||
show: (tid: number) => `/skinlib/show/${tid}`,
|
||||
},
|
||||
texture: {
|
||||
delete: (texture: number) => `/texture/${texture}`,
|
||||
info: (texture: number) => `/texture/${texture}`,
|
||||
name: (texture: number) => `/texture/${texture}/name`,
|
||||
privacy: (texture: number) => `/texture/${texture}/privacy`,
|
||||
type: (texture: number) => `/texture/${texture}/type`,
|
||||
upload: () => '/texture' as const,
|
||||
},
|
||||
user: {
|
||||
closet: {
|
||||
add: () => '/user/closet' as const,
|
||||
ids: () => '/user/closet/ids' as const,
|
||||
list: () => '/user/closet/list' as const,
|
||||
page: () => '/user/closet' as const,
|
||||
remove: (tid: number) => `/user/closet/${tid}`,
|
||||
rename: (tid: number) => `/user/closet/${tid}`,
|
||||
},
|
||||
home: () => '/user' as const,
|
||||
notification: (id: number) => `/user/notifications/${id}`,
|
||||
player: {
|
||||
add: () => '/user/player' as const,
|
||||
clear: (player: number) => `/user/player/${player}/textures`,
|
||||
delete: (player: number) => `/user/player/${player}`,
|
||||
list: () => '/user/player/list' as const,
|
||||
page: () => '/user/player' as const,
|
||||
rename: (player: number) => `/user/player/${player}/name`,
|
||||
set: (player: number) => `/user/player/${player}/textures`,
|
||||
},
|
||||
profile: { avatar: () => '/user/profile/avatar' as const },
|
||||
score: () => '/user/score-info' as const,
|
||||
sign: () => '/user/sign' as const,
|
||||
},
|
||||
}
|
||||
admin: {
|
||||
players: {
|
||||
delete: (player: number) => `/admin/players/${player}`,
|
||||
list: () => '/admin/players/list' as const,
|
||||
name: (player: number) => `/admin/players/${player}/name`,
|
||||
owner: (player: number) => `/admin/players/${player}/owner`,
|
||||
texture: (player: number) => `/admin/players/${player}/textures`,
|
||||
},
|
||||
users: {
|
||||
delete: (user: number) => `/admin/users/${user}`,
|
||||
email: (user: number) => `/admin/users/${user}/email`,
|
||||
list: () => '/admin/users/list' as const,
|
||||
nickname: (user: number) => `/admin/users/${user}/nickname`,
|
||||
password: (user: number) => `/admin/users/${user}/password`,
|
||||
permission: (user: number) => `/admin/users/${user}/permission`,
|
||||
score: (user: number) => `/admin/users/${user}/score`,
|
||||
verification: (user: number) => `/admin/users/${user}/verification`,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
bind: () => '/auth/bind' as const,
|
||||
forgot: () => '/auth/forgot' as const,
|
||||
login: () => '/auth/login' as const,
|
||||
logout: () => '/auth/logout' as const,
|
||||
register: () => '/auth/register' as const,
|
||||
reset: (uid: number) => `/auth/reset/${uid}`,
|
||||
verify: (uid: number) => `/auth/verify/${uid}`,
|
||||
},
|
||||
skinlib: {
|
||||
home: () => '/skinlib' as const,
|
||||
info: (texture: number) => `/skinlib/info/${texture}`,
|
||||
list: () => '/skinlib/list' as const,
|
||||
show: (tid: number) => `/skinlib/show/${tid}`,
|
||||
},
|
||||
texture: {
|
||||
delete: (texture: number) => `/texture/${texture}`,
|
||||
info: (texture: number) => `/texture/${texture}`,
|
||||
name: (texture: number) => `/texture/${texture}/name`,
|
||||
privacy: (texture: number) => `/texture/${texture}/privacy`,
|
||||
type: (texture: number) => `/texture/${texture}/type`,
|
||||
upload: () => '/texture' as const,
|
||||
},
|
||||
user: {
|
||||
closet: {
|
||||
add: () => '/user/closet' as const,
|
||||
ids: () => '/user/closet/ids' as const,
|
||||
list: () => '/user/closet/list' as const,
|
||||
page: () => '/user/closet' as const,
|
||||
remove: (tid: number) => `/user/closet/${tid}`,
|
||||
rename: (tid: number) => `/user/closet/${tid}`,
|
||||
},
|
||||
home: () => '/user' as const,
|
||||
notification: (id: number) => `/user/notifications/${id}`,
|
||||
player: {
|
||||
add: () => '/user/player' as const,
|
||||
clear: (player: number) => `/user/player/${player}/textures`,
|
||||
delete: (player: number) => `/user/player/${player}`,
|
||||
list: () => '/user/player/list' as const,
|
||||
page: () => '/user/player' as const,
|
||||
rename: (player: number) => `/user/player/${player}/name`,
|
||||
set: (player: number) => `/user/player/${player}/textures`,
|
||||
},
|
||||
profile: {avatar: () => '/user/profile/avatar' as const},
|
||||
score: () => '/user/score-info' 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 { ModalOptions, ModalResult } from './components/Modal'
|
||||
import { Toast } from './scripts/toast'
|
||||
import type {ModalOptions, ModalResult} from './components/Modal';
|
||||
import type {Toast} from './scripts/toast';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-redeclare
|
||||
let blessing: {
|
||||
base_url: string
|
||||
debug: boolean
|
||||
env: string
|
||||
locale: string
|
||||
site_name: string
|
||||
version: string
|
||||
route: string
|
||||
extra: any
|
||||
i18n: object
|
||||
let blessing: {
|
||||
base_url: string;
|
||||
debug: boolean;
|
||||
env: string;
|
||||
locale: string;
|
||||
site_name: string;
|
||||
version: string;
|
||||
route: string;
|
||||
extra: Record<string, unknown>;
|
||||
i18n: Record<string, unknown>;
|
||||
|
||||
fetch: {
|
||||
get(url: string, params?: object): Promise<object>
|
||||
post(url: string, data?: object): Promise<object>
|
||||
put(url: string, data?: object): Promise<object>
|
||||
del(url: string, data?: object): Promise<object>
|
||||
}
|
||||
fetch: {
|
||||
get: (url: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
post: (url: string, data?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
put: (url: string, data?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
del: (url: string, data?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
event: {
|
||||
on(eventName: string, listener: Function): void
|
||||
emit(eventName: string, payload: object): void
|
||||
}
|
||||
event: {
|
||||
on: (eventName: string, listener: (...args: any[]) => void) => void;
|
||||
emit: (eventName: string, payload: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
notify: {
|
||||
showModal(options?: ModalOptions): Promise<ModalResult>
|
||||
toast: Toast
|
||||
}
|
||||
}
|
||||
notify: {
|
||||
showModal: (options?: ModalOptions) => Promise<ModalResult>;
|
||||
toast: Toast;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
@import 'spectre.css/src/spectre.scss';
|
||||
@import '@/fonts/minecraft.css';
|
||||
|
||||
body {
|
||||
height: 97vh;
|
||||
}
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
export const enum Breakpoint {
|
||||
xs = 0,
|
||||
sm = 576,
|
||||
md = 768,
|
||||
lg = 992,
|
||||
xl = 1200,
|
||||
xs = 0,
|
||||
sm = 576,
|
||||
md = 768,
|
||||
lg = 992,
|
||||
xl = 1200,
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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`
|
||||
cursor: pointer;
|
||||
`
|
||||
`;
|
||||
|
||||
export const center = css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,83 +1,79 @@
|
|||
/* eslint-disable object-curly-newline */
|
||||
import { fromEvent, merge, of, partition } from 'rxjs'
|
||||
import { filter, map, pairwise } from 'rxjs/operators'
|
||||
|
||||
import {
|
||||
fromEvent,
|
||||
merge,
|
||||
of,
|
||||
partition,
|
||||
} from 'rxjs';
|
||||
import {filter, map, pairwise} from 'rxjs/operators';
|
||||
|
||||
export function registerNavbarPicker(
|
||||
navbar: HTMLElement,
|
||||
picker: HTMLDivElement,
|
||||
init: string,
|
||||
navbar: HTMLElement,
|
||||
picker: HTMLDivElement,
|
||||
init: string,
|
||||
): void {
|
||||
const color$ = fromEvent(picker, 'click').pipe(
|
||||
map((event) => event.target as HTMLElement),
|
||||
filter(
|
||||
(element): element is HTMLInputElement => element.tagName === 'INPUT',
|
||||
),
|
||||
map((element) => element.value),
|
||||
)
|
||||
const color$ = fromEvent(picker, 'click').pipe(
|
||||
map(event => event.target as HTMLElement),
|
||||
filter((element): element is HTMLInputElement => element.tagName === 'INPUT'),
|
||||
map(element => element.value),
|
||||
);
|
||||
|
||||
merge(of(init), color$)
|
||||
.pipe(pairwise())
|
||||
.subscribe(([previous, current]) => {
|
||||
navbar.classList.replace(`navbar-${previous}`, `navbar-${current}`)
|
||||
})
|
||||
merge(of(init), color$)
|
||||
.pipe(pairwise())
|
||||
.subscribe(([previous, current]) => {
|
||||
navbar.classList.replace(`navbar-${previous}`, `navbar-${current}`);
|
||||
});
|
||||
|
||||
const [light$, dark$] = partition(color$, (color) =>
|
||||
['light', 'warning', 'white', 'orange', 'lime'].includes(color),
|
||||
)
|
||||
light$.subscribe(() => {
|
||||
// DO NOT use `classList.replace`.
|
||||
navbar.classList.remove('navbar-dark')
|
||||
navbar.classList.add('navbar-light')
|
||||
})
|
||||
dark$.subscribe(() => {
|
||||
// DO NOT use `classList.replace`.
|
||||
navbar.classList.remove('navbar-light')
|
||||
navbar.classList.add('navbar-dark')
|
||||
})
|
||||
const [light$, dark$] = partition(color$, color =>
|
||||
['light', 'warning', 'white', 'orange', 'lime'].includes(color));
|
||||
light$.subscribe(() => {
|
||||
// DO NOT use `classList.replace`.
|
||||
navbar.classList.remove('navbar-dark');
|
||||
navbar.classList.add('navbar-light');
|
||||
});
|
||||
dark$.subscribe(() => {
|
||||
// DO NOT use `classList.replace`.
|
||||
navbar.classList.remove('navbar-light');
|
||||
navbar.classList.add('navbar-dark');
|
||||
});
|
||||
}
|
||||
|
||||
const navbar = document.querySelector<HTMLElement>('.wrapper > nav')
|
||||
const picker = document.querySelector<HTMLDivElement>('#navbar-color-picker')
|
||||
/* istanbul ignore next */
|
||||
const navbar = document.querySelector<HTMLElement>('.wrapper > nav');
|
||||
const picker = document.querySelector<HTMLDivElement>('#navbar-color-picker');
|
||||
|
||||
if (navbar && picker) {
|
||||
registerNavbarPicker(navbar, picker, blessing.extra.navbar || 'white')
|
||||
registerNavbarPicker(navbar, picker, blessing.extra.navbar as string || 'white');
|
||||
}
|
||||
|
||||
export function registerSidebarPicker(
|
||||
sidebar: HTMLElement,
|
||||
{ dark, light }: { dark: HTMLDivElement; light: HTMLDivElement },
|
||||
init: string,
|
||||
sidebar: HTMLElement,
|
||||
{dark, light}: {dark: HTMLDivElement; light: HTMLDivElement},
|
||||
init: string,
|
||||
): void {
|
||||
const color$ = merge(
|
||||
fromEvent(dark, 'click'),
|
||||
fromEvent(light, 'click'),
|
||||
).pipe(
|
||||
map((event) => event.target as HTMLElement),
|
||||
filter(
|
||||
(element): element is HTMLInputElement => element.tagName === 'INPUT',
|
||||
),
|
||||
map((element) => element.value),
|
||||
)
|
||||
const color$ = merge(
|
||||
fromEvent(dark, 'click'),
|
||||
fromEvent(light, 'click'),
|
||||
).pipe(
|
||||
map(event => event.target as HTMLElement),
|
||||
filter((element): element is HTMLInputElement => element.tagName === 'INPUT'),
|
||||
map(element => element.value),
|
||||
);
|
||||
|
||||
merge(of(init), color$)
|
||||
.pipe(pairwise())
|
||||
.subscribe(([previous, current]) => {
|
||||
sidebar.classList.replace(`sidebar-${previous}`, `sidebar-${current}`)
|
||||
})
|
||||
merge(of(init), color$)
|
||||
.pipe(pairwise())
|
||||
.subscribe(([previous, current]) => {
|
||||
sidebar.classList.replace(`sidebar-${previous}`, `sidebar-${current}`);
|
||||
});
|
||||
}
|
||||
|
||||
const sidebar = document.querySelector<HTMLElement>('.main-sidebar')
|
||||
const darkPicker = document.querySelector<HTMLDivElement>(
|
||||
'#sidebar-dark-picker',
|
||||
)
|
||||
const lightPicker = document.querySelector<HTMLDivElement>(
|
||||
'#sidebar-light-picker',
|
||||
)
|
||||
/* istanbul ignore next */
|
||||
const sidebar = document.querySelector<HTMLElement>('.main-sidebar');
|
||||
const darkPicker = document.querySelector<HTMLDivElement>('#sidebar-dark-picker');
|
||||
const lightPicker = document.querySelector<HTMLDivElement>('#sidebar-light-picker');
|
||||
|
||||
if (sidebar && darkPicker && lightPicker) {
|
||||
registerSidebarPicker(
|
||||
sidebar,
|
||||
{ dark: darkPicker, light: lightPicker },
|
||||
blessing.extra.sidebar || 'dark-primary',
|
||||
)
|
||||
registerSidebarPicker(
|
||||
sidebar,
|
||||
{dark: darkPicker, light: lightPicker},
|
||||
blessing.extra.sidebar as string || 'dark-primary',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,120 +1,116 @@
|
|||
import * as echarts from 'echarts/core'
|
||||
import { SVGRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import {LineChart} from 'echarts/charts';
|
||||
import {
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components'
|
||||
import { get } from '../../scripts/net'
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import * as echarts from 'echarts/core';
|
||||
import {SVGRenderer} from 'echarts/renderers';
|
||||
import {get} from '../../scripts/net';
|
||||
|
||||
interface ChartData {
|
||||
labels: string[]
|
||||
xAxis: string[]
|
||||
data: number[][]
|
||||
}
|
||||
type ChartData = {
|
||||
labels: string[];
|
||||
xAxis: string[];
|
||||
data: number[][];
|
||||
};
|
||||
|
||||
interface SingleChartData {
|
||||
label: string
|
||||
xAxis: string[]
|
||||
data: number[]
|
||||
}
|
||||
type SingleChartData = {
|
||||
label: string;
|
||||
xAxis: string[];
|
||||
data: number[];
|
||||
};
|
||||
|
||||
echarts.use([
|
||||
SVGRenderer,
|
||||
LineChart,
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
])
|
||||
SVGRenderer,
|
||||
LineChart,
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
]);
|
||||
|
||||
async function main() {
|
||||
const elUsersRegistration = document.querySelector<HTMLDivElement>(
|
||||
'#chart-users-registration',
|
||||
)
|
||||
const elTexturesUpload = document.querySelector<HTMLDivElement>(
|
||||
'#chart-textures-upload',
|
||||
)
|
||||
if (!elUsersRegistration || !elTexturesUpload) {
|
||||
return
|
||||
}
|
||||
const elementUsersRegistration = document.querySelector<HTMLDivElement>('#chart-users-registration');
|
||||
const elementTexturesUpload = document.querySelector<HTMLDivElement>('#chart-textures-upload');
|
||||
if (!elementUsersRegistration || !elementTexturesUpload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDarkMode = document.body.classList.contains('dark-mode')
|
||||
const textColor = isDarkMode ? '#fff' : '#000'
|
||||
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||
const textColor = isDarkMode ? '#fff' : '#000';
|
||||
|
||||
const chartData: ChartData = await get('/admin/chart')
|
||||
createLineChart(
|
||||
elUsersRegistration,
|
||||
isDarkMode ? '#3498db' : '#17a2b8',
|
||||
textColor,
|
||||
{
|
||||
label: chartData.labels[0]!,
|
||||
xAxis: chartData.xAxis,
|
||||
data: chartData.data[0]!,
|
||||
},
|
||||
)
|
||||
createLineChart(elTexturesUpload, '#6f42c1', textColor, {
|
||||
label: chartData.labels[1]!,
|
||||
xAxis: chartData.xAxis,
|
||||
data: chartData.data[1]!,
|
||||
})
|
||||
const chartData: ChartData = await get('/admin/chart');
|
||||
createLineChart(
|
||||
elementUsersRegistration,
|
||||
isDarkMode ? '#3498db' : '#17a2b8',
|
||||
textColor,
|
||||
{
|
||||
label: chartData.labels[0],
|
||||
xAxis: chartData.xAxis,
|
||||
data: chartData.data[0],
|
||||
},
|
||||
);
|
||||
createLineChart(elementTexturesUpload, '#6f42c1', textColor, {
|
||||
label: chartData.labels[1],
|
||||
xAxis: chartData.xAxis,
|
||||
data: chartData.data[1],
|
||||
});
|
||||
}
|
||||
|
||||
function createLineChart(
|
||||
el: HTMLDivElement,
|
||||
color: string,
|
||||
textColor: string,
|
||||
data: SingleChartData,
|
||||
element: HTMLDivElement,
|
||||
color: string,
|
||||
textColor: string,
|
||||
data: SingleChartData,
|
||||
) {
|
||||
const chart = echarts.init(el)
|
||||
chart.setOption({
|
||||
title: {
|
||||
text: data.label,
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
},
|
||||
},
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside', start: 75 },
|
||||
{ type: 'slider', start: 75 },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: data.xAxis,
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
boundaryGap: false,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: data.label,
|
||||
type: 'line',
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
areaStyle: {
|
||||
color,
|
||||
},
|
||||
data: data.data,
|
||||
smooth: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
const chart = echarts.init(element);
|
||||
chart.setOption({
|
||||
title: {
|
||||
text: data.label,
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
},
|
||||
},
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
dataZoom: [
|
||||
{type: 'inside', start: 75},
|
||||
{type: 'slider', start: 75},
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: data.xAxis,
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
boundaryGap: false,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: data.label,
|
||||
type: 'line',
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
areaStyle: {
|
||||
color,
|
||||
},
|
||||
data: data.data,
|
||||
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 {
|
||||
player: Player
|
||||
onUpdateName(): void
|
||||
onUpdateOwner(): void
|
||||
onUpdateTexture(): void
|
||||
onDelete(): void
|
||||
}
|
||||
import type {Player} from '@/scripts/types';
|
||||
import {t} from '@/scripts/i18n';
|
||||
import {showModal} from '@/scripts/notify';
|
||||
import clsx from 'clsx';
|
||||
import {Box} from './styles';
|
||||
|
||||
const Card: React.FC<Props> = (props) => {
|
||||
const { player } = props
|
||||
type Props = {
|
||||
readonly player: Player;
|
||||
onUpdateName: () => void;
|
||||
onUpdateOwner: () => void;
|
||||
onUpdateTexture: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const handlePreviewTextures = () => {
|
||||
const skinPreview = `${blessing.base_url}/preview/${player.tid_skin}`
|
||||
const skinPreviewPNG = `${skinPreview}?png`
|
||||
const capePreview = `${blessing.base_url}/preview/${player.tid_cape}`
|
||||
const capePreviewPNG = `${capePreview}?png`
|
||||
const Card: React.FC<Props> = props => {
|
||||
const {player} = props;
|
||||
|
||||
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"
|
||||
>
|
||||
<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 handlePreviewTextures = () => {
|
||||
const skinPreview = `${blessing.base_url}/preview/${player.tid_skin}`;
|
||||
const skinPreviewPNG = `${skinPreview}?png`;
|
||||
const capePreview = `${blessing.base_url}/preview/${player.tid_cape}`;
|
||||
const capePreviewPNG = `${capePreview}?png`;
|
||||
|
||||
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 avatarPNG = `${avatar}?png`
|
||||
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||
|
||||
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"></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>
|
||||
)
|
||||
}
|
||||
const avatar = `${blessing.base_url}/avatar/player/${player.name}`;
|
||||
const avatarPNG = `${avatar}?png`;
|
||||
|
||||
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 Skeleton from 'react-loading-skeleton'
|
||||
import { Box } from './styles'
|
||||
import clsx from 'clsx'
|
||||
import styled from '@emotion/styled';
|
||||
import clsx from 'clsx';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import {Box} from './styles';
|
||||
|
||||
const isDarkMode = document.body.classList.contains('dark-mode')
|
||||
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||
|
||||
const ShrinkedSkeleton = styled(Skeleton)<{ width?: string }>`
|
||||
width: ${(props) => props.width};
|
||||
`
|
||||
const ShrinkedSkeleton = styled(Skeleton)<{width?: string}>`
|
||||
width: ${props => props.width};
|
||||
`;
|
||||
|
||||
const LoadingCard: React.FC = () => (
|
||||
<Box className={clsx('info-box', { 'bg-gray-dark': isDarkMode })}>
|
||||
<div className="info-box-icon">
|
||||
<Skeleton circle height={50} width={50} />
|
||||
</div>
|
||||
<div className="info-box-content">
|
||||
<div className="row">
|
||||
<div className="col-10">
|
||||
<ShrinkedSkeleton width="120px" />
|
||||
</div>
|
||||
<div className="col-2"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<ShrinkedSkeleton width="150px" />
|
||||
</div>
|
||||
<div>
|
||||
<ShrinkedSkeleton width="180px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export default LoadingCard
|
||||
export default function LoadingCard() {
|
||||
return (
|
||||
<Box className={clsx('info-box', {'bg-gray-dark': isDarkMode})}>
|
||||
<div className='info-box-icon'>
|
||||
<Skeleton circle height={50} width={50}/>
|
||||
</div>
|
||||
<div className='info-box-content'>
|
||||
<div className='row'>
|
||||
<div className='col-10'>
|
||||
<ShrinkedSkeleton width='120px'/>
|
||||
</div>
|
||||
<div className='col-2'/>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<ShrinkedSkeleton width='150px'/>
|
||||
</div>
|
||||
<div>
|
||||
<ShrinkedSkeleton width='180px'/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
import React from 'react'
|
||||
import styled from '@emotion/styled'
|
||||
import Skeleton from 'react-loading-skeleton'
|
||||
import styled from '@emotion/styled';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
const ThickSkeleton = styled(Skeleton)`
|
||||
line-height: 2;
|
||||
`
|
||||
`;
|
||||
|
||||
const LoadingRow: React.FC = () => (
|
||||
<tr>
|
||||
<td colSpan={6}>
|
||||
<ThickSkeleton />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
export default LoadingRow
|
||||
export default function LoadingRow() {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={6}>
|
||||
<ThickSkeleton/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,84 +1,84 @@
|
|||
import React, { useState } from 'react'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import { TextureType } from '@/scripts/types'
|
||||
import Modal from '@/components/Modal'
|
||||
import Modal from '@/components/Modal';
|
||||
import {t} from '@/scripts/i18n';
|
||||
import {TextureType} from '@/scripts/types';
|
||||
import {useState} from 'react';
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onSubmit(type: 'skin' | 'cape', tid: number): void
|
||||
onClose(): void
|
||||
}
|
||||
type Props = {
|
||||
readonly open: boolean;
|
||||
onSubmit: (type: 'skin' | 'cape', tid: number) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ModalUpdateTexture: React.FC<Props> = (props) => {
|
||||
const [type, setType] = useState<'skin' | 'cape'>('skin')
|
||||
const [tid, setTid] = useState('')
|
||||
const ModalUpdateTexture: React.FC<Props> = props => {
|
||||
const [type, setType] = useState<'skin' | 'cape'>('skin');
|
||||
const [tid, setTid] = useState('');
|
||||
|
||||
const handleTypeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setType(event.target.value as 'skin' | 'cape')
|
||||
}
|
||||
const handleTypeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setType(event.target.value as 'skin' | 'cape');
|
||||
};
|
||||
|
||||
const handleTidChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTid(event.target.value)
|
||||
}
|
||||
const handleTidChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTid(event.target.value);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
props.onSubmit(type, Number.parseInt(tid))
|
||||
setType('skin')
|
||||
setTid('')
|
||||
}
|
||||
const handleConfirm = () => {
|
||||
props.onSubmit(type, Number.parseInt(tid));
|
||||
setType('skin');
|
||||
setTid('');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setType('skin')
|
||||
setTid('')
|
||||
props.onClose()
|
||||
}
|
||||
const handleClose = () => {
|
||||
setType('skin');
|
||||
setTid('');
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={props.open}
|
||||
center
|
||||
title={t('admin.changeTexture')}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>{t('admin.textureType')}</label>
|
||||
<div>
|
||||
<label className="mr-5">
|
||||
<input
|
||||
className="mr-1"
|
||||
type="radio"
|
||||
value="skin"
|
||||
checked={type === 'skin'}
|
||||
onChange={handleTypeChange}
|
||||
/>
|
||||
{t('general.skin')}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
className="mr-1"
|
||||
type="radio"
|
||||
value="cape"
|
||||
checked={type === TextureType.Cape}
|
||||
onChange={handleTypeChange}
|
||||
/>
|
||||
{t('general.cape')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="update-texture-tid">TID</label>
|
||||
<input
|
||||
type="number"
|
||||
id="update-texture-tid"
|
||||
className="form-control"
|
||||
placeholder={t('admin.pidNotice')}
|
||||
value={tid}
|
||||
onChange={handleTidChange}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
center
|
||||
show={props.open}
|
||||
title={t('admin.changeTexture')}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className='form-group'>
|
||||
<label>{t('admin.textureType')}</label>
|
||||
<div>
|
||||
<label className='mr-5'>
|
||||
<input
|
||||
className='mr-1'
|
||||
type='radio'
|
||||
value='skin'
|
||||
checked={type === 'skin'}
|
||||
onChange={handleTypeChange}
|
||||
/>
|
||||
{t('general.skin')}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
className='mr-1'
|
||||
type='radio'
|
||||
value='cape'
|
||||
checked={type === TextureType.Cape}
|
||||
onChange={handleTypeChange}
|
||||
/>
|
||||
{t('general.cape')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<label htmlFor='update-texture-tid'>TID</label>
|
||||
<input
|
||||
type='number'
|
||||
id='update-texture-tid'
|
||||
className='form-control'
|
||||
placeholder={t('admin.pidNotice')}
|
||||
value={tid}
|
||||
onChange={handleTidChange}
|
||||
/>
|
||||
</div>
|
||||
</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 {
|
||||
player: Player
|
||||
onUpdateName(): void
|
||||
onUpdateOwner(): void
|
||||
onUpdateTexture(): void
|
||||
onDelete(): void
|
||||
}
|
||||
import type {Player} from '@/scripts/types';
|
||||
import ButtonEdit from '@/components/ButtonEdit';
|
||||
import {t} from '@/scripts/i18n';
|
||||
|
||||
const Row: React.FC<Props> = (props) => {
|
||||
const { player } = props
|
||||
type Props = {
|
||||
readonly player: Player;
|
||||
onUpdateName: () => void;
|
||||
onUpdateOwner: () => void;
|
||||
onUpdateTexture: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
const Row: React.FC<Props> = props => {
|
||||
const {player} = props;
|
||||
|
||||
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 { hot } from 'react-hot-loader/root'
|
||||
import { useImmer } from 'use-immer'
|
||||
import useIsLargeScreen from '@/scripts/hooks/useIsLargeScreen'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import type { Player, Paginator } from '@/scripts/types'
|
||||
import { toast, showModal } from '@/scripts/notify'
|
||||
import urls from '@/scripts/urls'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import Header from '../UsersManagement/Header'
|
||||
import Card from './Card'
|
||||
import LoadingCard from './LoadingCard'
|
||||
import Row from './Row'
|
||||
import LoadingRow from './LoadingRow'
|
||||
import ModalUpdateTexture from './ModalUpdateTexture'
|
||||
import type {Paginator, Player} from '@/scripts/types';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import useIsLargeScreen from '@/scripts/hooks/useIsLargeScreen';
|
||||
import {t} from '@/scripts/i18n';
|
||||
import * as fetch from '@/scripts/net';
|
||||
import {showModal, toast} from '@/scripts/notify';
|
||||
import urls from '@/scripts/urls';
|
||||
import {useEffect, useLayoutEffect, useState} from 'react';
|
||||
import {useImmer} from 'use-immer';
|
||||
import Header from '../UsersManagement/Header';
|
||||
import Card from './Card';
|
||||
import LoadingCard from './LoadingCard';
|
||||
import LoadingRow from './LoadingRow';
|
||||
import ModalUpdateTexture from './ModalUpdateTexture';
|
||||
import Row from './Row';
|
||||
|
||||
const PlayersManagement: React.FC = () => {
|
||||
const [players, setPlayers] = useImmer<Player[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const isLargeScreen = useIsLargeScreen()
|
||||
const [isTableMode, setIsTableMode] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [textureUpdating, setTextureUpdating] = useState(-1)
|
||||
function PlayersManagement() {
|
||||
const [players, setPlayers] = useImmer<Player[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isLargeScreen = useIsLargeScreen();
|
||||
const [isTableMode, setIsTableMode] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [textureUpdating, setTextureUpdating] = useState(-1);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isLargeScreen) {
|
||||
setIsTableMode(true)
|
||||
}
|
||||
}, [isLargeScreen])
|
||||
useLayoutEffect(() => {
|
||||
if (isLargeScreen) {
|
||||
setIsTableMode(true);
|
||||
}
|
||||
}, [isLargeScreen]);
|
||||
|
||||
const getPlayers = async () => {
|
||||
setIsLoading(true)
|
||||
const { data, last_page }: Paginator<Player> = await fetch.get(
|
||||
urls.admin.players.list(),
|
||||
{
|
||||
q: query,
|
||||
page,
|
||||
},
|
||||
)
|
||||
setTotalPages(last_page)
|
||||
setPlayers(() => data)
|
||||
setIsLoading(false)
|
||||
}
|
||||
const getPlayers = async () => {
|
||||
setIsLoading(true);
|
||||
const {data, last_page}: Paginator<Player> = await fetch.get(
|
||||
urls.admin.players.list(),
|
||||
{
|
||||
q: query,
|
||||
page: page.toString(),
|
||||
},
|
||||
);
|
||||
setTotalPages(last_page);
|
||||
setPlayers(() => data);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getPlayers()
|
||||
}, [page])
|
||||
useEffect(() => {
|
||||
getPlayers();
|
||||
}, [page]);
|
||||
|
||||
const handleModeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsTableMode(event.target.value === 'table')
|
||||
}
|
||||
const handleModeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsTableMode(event.target.value === 'table');
|
||||
};
|
||||
|
||||
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value)
|
||||
}
|
||||
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value);
|
||||
};
|
||||
|
||||
const handleSubmitQuery = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
getPlayers()
|
||||
}
|
||||
const handleSubmitQuery = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
getPlayers();
|
||||
};
|
||||
|
||||
const handleUpdateName = async (player: Player, index: number) => {
|
||||
let name: string
|
||||
try {
|
||||
const { value } = await showModal({
|
||||
mode: 'prompt',
|
||||
text: t('admin.changePlayerNameNotice'),
|
||||
input: player.name,
|
||||
validator: (value: string) => {
|
||||
if (!value) {
|
||||
return t('admin.emptyPlayerName')
|
||||
}
|
||||
},
|
||||
})
|
||||
name = value
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const handleUpdateName = async (player: Player, index: number) => {
|
||||
let name: string;
|
||||
try {
|
||||
const {value} = await showModal({
|
||||
mode: 'prompt',
|
||||
text: t('admin.changePlayerNameNotice'),
|
||||
input: player.name,
|
||||
validator(value: string) {
|
||||
if (!value) {
|
||||
return t('admin.emptyPlayerName');
|
||||
}
|
||||
},
|
||||
});
|
||||
name = value;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { code, message } = await fetch.put<fetch.ResponseBody>(
|
||||
urls.admin.players.name(player.pid),
|
||||
{ player_name: name },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
setPlayers((players) => {
|
||||
players[index]!.name = name
|
||||
})
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
const {code, message} = await fetch.put<fetch.ResponseBody>(
|
||||
urls.admin.players.name(player.pid),
|
||||
{player_name: name},
|
||||
);
|
||||
if (code === 0) {
|
||||
toast.success(message);
|
||||
setPlayers(players => {
|
||||
players[index].name = name;
|
||||
});
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateOwner = async (player: Player, index: number) => {
|
||||
let uid: number
|
||||
try {
|
||||
const { value } = await showModal({
|
||||
mode: 'prompt',
|
||||
text: t('admin.changePlayerOwner'),
|
||||
input: player.uid.toString(),
|
||||
inputMode: 'numeric',
|
||||
})
|
||||
uid = Number.parseInt(value)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const handleUpdateOwner = async (player: Player, index: number) => {
|
||||
let uid: number;
|
||||
try {
|
||||
const {value} = await showModal({
|
||||
mode: 'prompt',
|
||||
text: t('admin.changePlayerOwner'),
|
||||
input: player.uid.toString(),
|
||||
inputMode: 'numeric',
|
||||
});
|
||||
uid = Number.parseInt(value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { code, message } = await fetch.put<fetch.ResponseBody>(
|
||||
urls.admin.players.owner(player.pid),
|
||||
{ uid },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
setPlayers((players) => {
|
||||
players[index]!.uid = uid
|
||||
})
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
const {code, message} = await fetch.put<fetch.ResponseBody>(
|
||||
urls.admin.players.owner(player.pid),
|
||||
{uid},
|
||||
);
|
||||
if (code === 0) {
|
||||
toast.success(message);
|
||||
setPlayers(players => {
|
||||
players[index].uid = uid;
|
||||
});
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModalUpdateTexture = () => setTextureUpdating(-1)
|
||||
const handleCloseModalUpdateTexture = () => {
|
||||
setTextureUpdating(-1);
|
||||
};
|
||||
|
||||
const handleUpdateTexture = async (type: 'skin' | 'cape', tid: number) => {
|
||||
const { code, message } = await fetch.put<fetch.ResponseBody>(
|
||||
urls.admin.players.texture(players[textureUpdating]!.pid),
|
||||
{ type, tid },
|
||||
)
|
||||
const handleUpdateTexture = async (type: 'skin' | 'cape', tid: number) => {
|
||||
const {code, message} = await fetch.put<fetch.ResponseBody>(
|
||||
urls.admin.players.texture(players[textureUpdating].pid),
|
||||
{type, tid},
|
||||
);
|
||||
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
setPlayers((players) => {
|
||||
const field = `tid_${type}` as const
|
||||
players[textureUpdating]![field] = tid
|
||||
})
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
if (code === 0) {
|
||||
toast.success(message);
|
||||
setPlayers(players => {
|
||||
const field = `tid_${type}` as const;
|
||||
players[textureUpdating][field] = tid;
|
||||
});
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (player: Player) => {
|
||||
try {
|
||||
await showModal({
|
||||
text: t('admin.deletePlayerNotice'),
|
||||
okButtonType: 'danger',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const handleDelete = async (player: Player) => {
|
||||
try {
|
||||
await showModal({
|
||||
text: t('admin.deletePlayerNotice'),
|
||||
okButtonType: 'danger',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { code, message } = await fetch.del<fetch.ResponseBody>(
|
||||
urls.admin.players.delete(player.pid),
|
||||
)
|
||||
if (code === 0) {
|
||||
setPlayers((players) => players.filter(({ pid }) => pid !== player.pid))
|
||||
toast.success(message)
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
const {code, message} = await fetch.del<fetch.ResponseBody>(urls.admin.players.delete(player.pid));
|
||||
if (code === 0) {
|
||||
setPlayers(players => players.filter(({pid}) => pid !== player.pid));
|
||||
toast.success(message);
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<Header className="card-header">
|
||||
<form className="input-group" onSubmit={handleSubmitQuery}>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="search"
|
||||
className="form-control"
|
||||
title={t('vendor.datatable.search')}
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
/>
|
||||
<div className="input-group-append">
|
||||
<button className="btn btn-primary" type="submit">
|
||||
{t('vendor.datatable.search')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="btn-group btn-group-toggle">
|
||||
<label
|
||||
className={`btn btn-secondary ${isTableMode ? 'active' : ''}`}
|
||||
title="Table Mode"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
value="table"
|
||||
checked={isTableMode}
|
||||
onChange={handleModeChange}
|
||||
/>
|
||||
<i className="fas fa-list"></i>
|
||||
</label>
|
||||
<label
|
||||
className={`btn btn-secondary ${isTableMode ? '' : 'active'}`}
|
||||
title="Card Mode"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
value="card"
|
||||
checked={!isTableMode}
|
||||
onChange={handleModeChange}
|
||||
/>
|
||||
<i className="fas fa-grip-vertical"></i>
|
||||
</label>
|
||||
</div>
|
||||
</Header>
|
||||
{players.length === 0 && !isLoading ? (
|
||||
<div className="card-body text-center">{t('general.noResult')}</div>
|
||||
) : isTableMode ? (
|
||||
<div className="card-body table-responsive p-0">
|
||||
<table className={`table ${isLoading ? '' : 'table-striped'}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PID</th>
|
||||
<th>{t('general.player.player-name')}</th>
|
||||
<th>{t('general.player.owner')}</th>
|
||||
<th>{t('general.player.previews')}</th>
|
||||
<th>{t('general.player.last-modified')}</th>
|
||||
<th>{t('admin.operationsTitle')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? new Array(10).fill(null).map((_, i) => <LoadingRow key={i} />)
|
||||
: players.map((player, i) => (
|
||||
<Row
|
||||
key={player.pid}
|
||||
player={player}
|
||||
onUpdateName={() => handleUpdateName(player, i)}
|
||||
onUpdateOwner={() => handleUpdateOwner(player, i)}
|
||||
onUpdateTexture={() => setTextureUpdating(i)}
|
||||
onDelete={() => handleDelete(player)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-body d-flex flex-wrap">
|
||||
{isLoading
|
||||
? new Array(10).fill(null).map((_, i) => <LoadingCard key={i} />)
|
||||
: players.map((player, i) => (
|
||||
<Card
|
||||
key={player.pid}
|
||||
player={player}
|
||||
onUpdateName={() => handleUpdateName(player, i)}
|
||||
onUpdateOwner={() => handleUpdateOwner(player, i)}
|
||||
onUpdateTexture={() => setTextureUpdating(i)}
|
||||
onDelete={() => handleDelete(player)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-footer">
|
||||
<div className="float-right">
|
||||
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
<ModalUpdateTexture
|
||||
open={textureUpdating > -1}
|
||||
onSubmit={handleUpdateTexture}
|
||||
onClose={handleCloseModalUpdateTexture}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className='card'>
|
||||
<Header className='card-header'>
|
||||
<form className='input-group' onSubmit={handleSubmitQuery}>
|
||||
<input
|
||||
type='text'
|
||||
inputMode='search'
|
||||
className='form-control'
|
||||
title={t('vendor.datatable.search')}
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
/>
|
||||
<div className='input-group-append'>
|
||||
<button className='btn btn-primary' type='submit'>
|
||||
{t('vendor.datatable.search')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className='btn-group btn-group-toggle'>
|
||||
<label
|
||||
className={`btn btn-secondary ${isTableMode ? 'active' : ''}`}
|
||||
title='Table Mode'
|
||||
>
|
||||
<input
|
||||
type='radio'
|
||||
value='table'
|
||||
checked={isTableMode}
|
||||
onChange={handleModeChange}
|
||||
/>
|
||||
<i className='fas fa-list'/>
|
||||
</label>
|
||||
<label
|
||||
className={`btn btn-secondary ${isTableMode ? '' : 'active'}`}
|
||||
title='Card Mode'
|
||||
>
|
||||
<input
|
||||
type='radio'
|
||||
value='card'
|
||||
checked={!isTableMode}
|
||||
onChange={handleModeChange}
|
||||
/>
|
||||
<i className='fas fa-grip-vertical'/>
|
||||
</label>
|
||||
</div>
|
||||
</Header>
|
||||
{players.length === 0 && !isLoading
|
||||
? <div className='card-body text-center'>{t('general.noResult')}</div>
|
||||
: isTableMode
|
||||
? (
|
||||
<div className='card-body table-responsive p-0'>
|
||||
<table className={`table ${isLoading ? '' : 'table-striped'}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PID</th>
|
||||
<th>{t('general.player.player-name')}</th>
|
||||
<th>{t('general.player.owner')}</th>
|
||||
<th>{t('general.player.previews')}</th>
|
||||
<th>{t('general.player.last-modified')}</th>
|
||||
<th>{t('admin.operationsTitle')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? Array.from({length: 10}).fill(null).map((_, i) => <LoadingRow key={i}/>)
|
||||
: players.map((player, i) => (
|
||||
<Row
|
||||
key={player.pid}
|
||||
player={player}
|
||||
onUpdateName={async () => handleUpdateName(player, i)}
|
||||
onUpdateOwner={async () => handleUpdateOwner(player, i)}
|
||||
onUpdateTexture={() => {
|
||||
setTextureUpdating(i);
|
||||
}}
|
||||
onDelete={async () => handleDelete(player)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='card-body d-flex flex-wrap'>
|
||||
{isLoading
|
||||
? Array.from({length: 10}).fill(null).map((_, i) => <LoadingCard key={i}/>)
|
||||
: players.map((player, i) => (
|
||||
<Card
|
||||
key={player.pid}
|
||||
player={player}
|
||||
onUpdateName={async () => handleUpdateName(player, i)}
|
||||
onUpdateOwner={async () => handleUpdateOwner(player, i)}
|
||||
onUpdateTexture={() => {
|
||||
setTextureUpdating(i);
|
||||
}}
|
||||
onDelete={async () => handleDelete(player)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className='card-footer'>
|
||||
<div className='float-right'>
|
||||
<Pagination page={page} totalPages={totalPages} onChange={setPage}/>
|
||||
</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`
|
||||
width: 48%;
|
||||
|
|
@ -8,4 +8,4 @@ export const Box = styled.div`
|
|||
${breakpoints.lessThan(breakpoints.Breakpoint.lg)} {
|
||||
width: 98%;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react'
|
||||
import styled from '@emotion/styled'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import type { Plugin } from './types'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type {Plugin} from './types';
|
||||
import {t} from '@/scripts/i18n';
|
||||
import styled from '@emotion/styled';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const Box = styled.div`
|
||||
cursor: default;
|
||||
|
|
@ -15,7 +15,7 @@ const Box = styled.div`
|
|||
.info-box-content {
|
||||
max-width: calc(100% - 70px);
|
||||
}
|
||||
`
|
||||
`;
|
||||
const ActionButton = styled.a`
|
||||
transition-property: color;
|
||||
transition-duration: 0.3s;
|
||||
|
|
@ -29,99 +29,102 @@ const ActionButton = styled.a`
|
|||
&:not(:last-child) {
|
||||
margin-right: 9px;
|
||||
}
|
||||
`
|
||||
`;
|
||||
const Header = styled.div`
|
||||
max-width: calc(100% - 40px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
`;
|
||||
const Description = styled.div`
|
||||
font-size: 14px;
|
||||
`
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
plugin: Plugin
|
||||
onEnable(plugin: Plugin): void
|
||||
onDisable(plugin: Plugin): void
|
||||
onDelete(plugin: Plugin): void
|
||||
baseUrl: string
|
||||
}
|
||||
type Props = {
|
||||
readonly plugin: Plugin;
|
||||
onEnable: (plugin: Plugin) => void;
|
||||
onDisable: (plugin: Plugin) => void;
|
||||
onDelete: (plugin: Plugin) => void;
|
||||
readonly baseUrl: string;
|
||||
};
|
||||
|
||||
const InfoBox: React.FC<Props> = (props) => {
|
||||
const { plugin } = props
|
||||
const InfoBox: React.FC<Props> = props => {
|
||||
const {plugin} = props;
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
event.preventDefault()
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.target.checked) {
|
||||
props.onEnable(plugin)
|
||||
} else {
|
||||
props.onDisable(plugin)
|
||||
}
|
||||
}
|
||||
if (event.target.checked) {
|
||||
props.onEnable(plugin);
|
||||
} else {
|
||||
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 (
|
||||
<Box className={clsx('info-box', 'mr-3', { 'bg-gray-dark': isDarkMode })}>
|
||||
<span className={`info-box-icon bg-${plugin.icon.bg}`}>
|
||||
<i className={`${plugin.icon.faType} fa-${plugin.icon.fa}`} />
|
||||
</span>
|
||||
<div className="info-box-content">
|
||||
<div className="d-flex justify-content-between">
|
||||
<Header>
|
||||
<input
|
||||
className="mr-2 d-inline-block"
|
||||
type="checkbox"
|
||||
checked={plugin.enabled}
|
||||
title={
|
||||
plugin.enabled
|
||||
? t('admin.disablePlugin')
|
||||
: t('admin.enablePlugin')
|
||||
}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<strong className="d-inline-block mr-2 text-truncate">
|
||||
{plugin.title}
|
||||
</strong>
|
||||
<span className="d-none d-sm-inline-block text-gray">
|
||||
v{plugin.version}
|
||||
</span>
|
||||
</Header>
|
||||
<div>
|
||||
{plugin.readme && (
|
||||
<ActionButton
|
||||
href={`${props.baseUrl}/admin/plugins/readme/${plugin.name}`}
|
||||
title={t('admin.pluginReadme')}
|
||||
>
|
||||
<i className="fas fa-question" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{plugin.enabled && plugin.config && (
|
||||
<ActionButton
|
||||
href={`${props.baseUrl}/admin/plugins/config/${plugin.name}`}
|
||||
title={t('admin.configurePlugin')}
|
||||
>
|
||||
<i className="fas fa-cog" />
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
href="#"
|
||||
title={t('admin.deletePlugin')}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<i className="fas fa-trash" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
<Description className="mt-2 text-truncate" title={plugin.description}>
|
||||
{plugin.description}
|
||||
</Description>
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Box className={clsx('info-box', 'mr-3', {'bg-gray-dark': isDarkMode})}>
|
||||
<span className={`info-box-icon bg-${plugin.icon.bg}`}>
|
||||
<i className={`${plugin.icon.faType} fa-${plugin.icon.fa}`}/>
|
||||
</span>
|
||||
<div className='info-box-content'>
|
||||
<div className='d-flex justify-content-between'>
|
||||
<Header>
|
||||
<input
|
||||
className='mr-2 d-inline-block'
|
||||
type='checkbox'
|
||||
checked={plugin.enabled}
|
||||
title={
|
||||
plugin.enabled
|
||||
? t('admin.disablePlugin')
|
||||
: t('admin.enablePlugin')
|
||||
}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<strong className='d-inline-block mr-2 text-truncate'>
|
||||
{plugin.title}
|
||||
</strong>
|
||||
<span className='d-none d-sm-inline-block text-gray'>
|
||||
v
|
||||
{plugin.version}
|
||||
</span>
|
||||
</Header>
|
||||
<div>
|
||||
{plugin.readme && (
|
||||
<ActionButton
|
||||
href={`${props.baseUrl}/admin/plugins/readme/${plugin.name}`}
|
||||
title={t('admin.pluginReadme')}
|
||||
>
|
||||
<i className='fas fa-question'/>
|
||||
</ActionButton>
|
||||
)}
|
||||
{plugin.enabled && plugin.config && (
|
||||
<ActionButton
|
||||
href={`${props.baseUrl}/admin/plugins/config/${plugin.name}`}
|
||||
title={t('admin.configurePlugin')}
|
||||
>
|
||||
<i className='fas fa-cog'/>
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
href='#'
|
||||
title={t('admin.deletePlugin')}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<i className='fas fa-trash'/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
<Description className='mt-2 text-truncate' title={plugin.description}>
|
||||
{plugin.description}
|
||||
</Description>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoBox
|
||||
export default InfoBox;
|
||||
|
|
|
|||
|
|
@ -1,247 +1,244 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { hot } from 'react-hot-loader/root'
|
||||
import { useImmer } from 'use-immer'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { toast, showModal } from '@/scripts/notify'
|
||||
import FileInput from '@/components/FileInput'
|
||||
import Loading from '@/components/Loading'
|
||||
import InfoBox from './InfoBox'
|
||||
import type { Plugin } from './types'
|
||||
import type {Plugin} from './types';
|
||||
import FileInput from '@/components/FileInput';
|
||||
import Loading from '@/components/Loading';
|
||||
import {t} from '@/scripts/i18n';
|
||||
import * as fetch from '@/scripts/net';
|
||||
import {showModal, toast} from '@/scripts/notify';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useImmer} from 'use-immer';
|
||||
import InfoBox from './InfoBox';
|
||||
|
||||
const PluginsManagement: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [plugins, setPlugins] = useImmer<Plugin[]>([])
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
function PluginsManagement() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [plugins, setPlugins] = useImmer<Plugin[]>([]);
|
||||
const [file, setFile] = useState<File | undefined>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const getPlugins = async () => {
|
||||
setIsLoading(true)
|
||||
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data')
|
||||
setPlugins(() => plugins)
|
||||
setIsLoading(false)
|
||||
}
|
||||
getPlugins()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const getPlugins = async () => {
|
||||
setIsLoading(true);
|
||||
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data');
|
||||
setPlugins(() => plugins);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleEnable = async (plugin: Plugin, i: number) => {
|
||||
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>
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
getPlugins();
|
||||
}, []);
|
||||
|
||||
const handleDisable = async (plugin: Plugin, i: number) => {
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
'/admin/plugins/manage',
|
||||
{
|
||||
action: 'disable',
|
||||
name: plugin.name,
|
||||
},
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
setPlugins((plugins) => {
|
||||
plugins[i]!.enabled = false
|
||||
})
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
const handleEnable = async (plugin: Plugin, i: number) => {
|
||||
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 handleDelete = async (plugin: Plugin) => {
|
||||
try {
|
||||
await showModal({
|
||||
title: plugin.title,
|
||||
text: t('admin.confirmDeletion'),
|
||||
okButtonType: 'danger',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const handleDisable = async (plugin: Plugin, i: number) => {
|
||||
const {code, message} = await fetch.post<fetch.ResponseBody>(
|
||||
'/admin/plugins/manage',
|
||||
{
|
||||
action: 'disable',
|
||||
name: plugin.name,
|
||||
},
|
||||
);
|
||||
if (code === 0) {
|
||||
toast.success(message);
|
||||
setPlugins(plugins => {
|
||||
plugins[i].enabled = false;
|
||||
});
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
'/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 handleDelete = async (plugin: Plugin) => {
|
||||
try {
|
||||
await showModal({
|
||||
title: plugin.title,
|
||||
text: t('admin.confirmDeletion'),
|
||||
okButtonType: 'danger',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFile(event.target.files![0]!)
|
||||
}
|
||||
const {code, message} = await fetch.post<fetch.ResponseBody>(
|
||||
'/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>) => {
|
||||
setUrl(event.target.value)
|
||||
}
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFile(event.target.files![0]);
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUrl(event.target.value);
|
||||
};
|
||||
|
||||
setIsUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, file.name)
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
'/admin/plugins/upload',
|
||||
formData,
|
||||
)
|
||||
const handleUpload = async () => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(false)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
setFile(null)
|
||||
setIsUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
const {code, message} = await fetch.post<fetch.ResponseBody>(
|
||||
'/admin/plugins/upload',
|
||||
formData,
|
||||
);
|
||||
|
||||
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data')
|
||||
setPlugins(() => plugins)
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
setIsUploading(false);
|
||||
if (code === 0) {
|
||||
toast.success(message);
|
||||
setFile(null);
|
||||
|
||||
const handleSubmitUrl = async () => {
|
||||
setIsDownloading(true)
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
'/admin/plugins/wget',
|
||||
{ url },
|
||||
)
|
||||
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data');
|
||||
setPlugins(() => plugins);
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
setIsDownloading(false)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
setUrl('')
|
||||
const handleSubmitUrl = async () => {
|
||||
setIsDownloading(true);
|
||||
const {code, message} = await fetch.post<fetch.ResponseBody>(
|
||||
'/admin/plugins/wget',
|
||||
{url},
|
||||
);
|
||||
|
||||
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data')
|
||||
setPlugins(() => plugins)
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
setIsDownloading(false);
|
||||
if (code === 0) {
|
||||
toast.success(message);
|
||||
setUrl('');
|
||||
|
||||
const chunks = Array(Math.ceil(plugins.length / 2))
|
||||
.fill(null)
|
||||
.map((_, i) => plugins.slice(i * 2, (i + 1) * 2) as [Plugin, Plugin?])
|
||||
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data');
|
||||
setPlugins(() => plugins);
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-lg-8">
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : plugins.length === 0 ? (
|
||||
t('general.noResult')
|
||||
) : (
|
||||
chunks.map((chunk, i) => (
|
||||
<div className="row" key={`${chunk[0].name}&${chunk[1]?.name}`}>
|
||||
{(chunk as Plugin[]).map((plugin, j) => (
|
||||
<div className="col-md-6" key={plugin.name}>
|
||||
<InfoBox
|
||||
plugin={plugin}
|
||||
onEnable={(plugin) => handleEnable(plugin, i * 2 + j)}
|
||||
onDisable={(plugin) => handleDisable(plugin, i * 2 + j)}
|
||||
onDelete={handleDelete}
|
||||
baseUrl={blessing.base_url}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="col-lg-4">
|
||||
<div className="card card-primary card-outline">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{t('admin.uploadArchive')}</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p>{t('admin.uploadArchiveNotice')}</p>
|
||||
<FileInput
|
||||
file={file}
|
||||
accept="application/zip"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button
|
||||
className="btn btn-primary float-right"
|
||||
disabled={isUploading}
|
||||
onClick={handleUpload}
|
||||
>
|
||||
{isUploading ? <Loading /> : t('general.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card card-primary card-outline">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{t('admin.downloadRemote')}</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p>{t('admin.downloadRemoteNotice')}</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="zip-url">URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="zip-url"
|
||||
className="form-control"
|
||||
inputMode="url"
|
||||
value={url}
|
||||
onChange={handleUrlChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button
|
||||
className="btn btn-primary float-right"
|
||||
disabled={isDownloading}
|
||||
onClick={handleSubmitUrl}
|
||||
>
|
||||
{isDownloading ? <Loading /> : t('general.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const chunks = Array.from({length: Math.ceil(plugins.length / 2)})
|
||||
.fill(null)
|
||||
.map((_, i) => plugins.slice(i * 2, (i + 1) * 2) as [Plugin, Plugin?]);
|
||||
|
||||
return (
|
||||
<div className='row'>
|
||||
<div className='col-lg-8'>
|
||||
{isLoading
|
||||
? <Loading/>
|
||||
: plugins.length === 0
|
||||
? t('general.noResult')
|
||||
: chunks.map((chunk, i) => (
|
||||
<div key={`${chunk[0].name}&${chunk[1]?.name}`} className='row'>
|
||||
{(chunk as Plugin[]).map((plugin, index) => (
|
||||
<div key={plugin.name} className='col-md-6'>
|
||||
<InfoBox
|
||||
plugin={plugin}
|
||||
baseUrl={blessing.base_url}
|
||||
onEnable={async plugin => handleEnable(plugin, i * 2 + index)}
|
||||
onDisable={async plugin => handleDisable(plugin, i * 2 + index)}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='col-lg-4'>
|
||||
<div className='card card-primary card-outline'>
|
||||
<div className='card-header'>
|
||||
<h3 className='card-title'>{t('admin.uploadArchive')}</h3>
|
||||
</div>
|
||||
<div className='card-body'>
|
||||
<p>{t('admin.uploadArchiveNotice')}</p>
|
||||
<FileInput
|
||||
file={file}
|
||||
accept='application/zip'
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='card-footer'>
|
||||
<button
|
||||
className='btn btn-primary float-right'
|
||||
disabled={isUploading}
|
||||
onClick={handleUpload}
|
||||
>
|
||||
{isUploading ? <Loading/> : t('general.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='card card-primary card-outline'>
|
||||
<div className='card-header'>
|
||||
<h3 className='card-title'>{t('admin.downloadRemote')}</h3>
|
||||
</div>
|
||||
<div className='card-body'>
|
||||
<p>{t('admin.downloadRemoteNotice')}</p>
|
||||
<div className='form-group'>
|
||||
<label htmlFor='zip-url'>URL</label>
|
||||
<input
|
||||
type='text'
|
||||
id='zip-url'
|
||||
className='form-control'
|
||||
inputMode='url'
|
||||
value={url}
|
||||
onChange={handleUrlChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='card-footer'>
|
||||
<button
|
||||
className='btn btn-primary float-right'
|
||||
disabled={isDownloading}
|
||||
onClick={handleSubmitUrl}
|
||||
>
|
||||
{isDownloading ? <Loading/> : t('general.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default hot(PluginsManagement)
|
||||
export default PluginsManagement;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
export type Plugin = {
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
version: string
|
||||
enabled: boolean
|
||||
config: boolean
|
||||
readme: boolean
|
||||
icon: { fa: string; faType: 'fas' | 'fab'; bg: string }
|
||||
}
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
config: boolean;
|
||||
readme: boolean;
|
||||
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 {
|
||||
plugin: Plugin
|
||||
isInstalling: boolean
|
||||
onInstall(): void
|
||||
onUpdate(): void
|
||||
}
|
||||
import type {Plugin} from './types';
|
||||
import {t} from '@/scripts/i18n';
|
||||
|
||||
const Row: React.FC<Props> = (props) => {
|
||||
const { plugin, isInstalling } = props
|
||||
type Props = {
|
||||
readonly plugin: Plugin;
|
||||
readonly isInstalling: boolean;
|
||||
onInstall: () => void;
|
||||
onUpdate: () => void;
|
||||
};
|
||||
|
||||
const allDeps = Object.entries(plugin.dependencies.all)
|
||||
const unsatisfied = Object.keys(plugin.dependencies.unsatisfied)
|
||||
const Row: React.FC<Props> = props => {
|
||||
const {plugin, isInstalling} = props;
|
||||
|
||||
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"></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>
|
||||
)
|
||||
}
|
||||
const allDeps = Object.entries(plugin.dependencies.all);
|
||||
const unsatisfied = Object.keys(plugin.dependencies.unsatisfied);
|
||||
|
||||
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 { hot } from 'react-hot-loader/root'
|
||||
import { enableMapSet } from 'immer'
|
||||
import { useImmer } from 'use-immer'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { toast, showModal } from '@/scripts/notify'
|
||||
import Loading from '@/components/Loading'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import type { Plugin } from './types'
|
||||
import Row from './Row'
|
||||
import type {Plugin} from './types';
|
||||
import Loading from '@/components/Loading';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import {t} from '@/scripts/i18n';
|
||||
import * as fetch from '@/scripts/net';
|
||||
import {showModal, toast} from '@/scripts/notify';
|
||||
import {enableMapSet} from 'immer';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useImmer} from 'use-immer';
|
||||
import Row from './Row';
|
||||
|
||||
enableMapSet()
|
||||
enableMapSet();
|
||||
|
||||
const PluginsMarket: React.FC = () => {
|
||||
const [plugins, setPlugins] = useImmer<Plugin[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [installings, setInstallings] = useImmer<Set<string>>(() => new Set())
|
||||
export default function PluginsMarket() {
|
||||
const [plugins, setPlugins] = useImmer<Plugin[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [installings, setInstallings] = useImmer<Set<string>>(() => new Set());
|
||||
|
||||
const searchedPlugins = useMemo(
|
||||
() =>
|
||||
plugins.filter(
|
||||
(plugin) =>
|
||||
plugin.name.includes(search) || plugin.title.includes(search),
|
||||
),
|
||||
[plugins, search],
|
||||
)
|
||||
const searchedPlugins = useMemo(
|
||||
() =>
|
||||
plugins.filter(plugin =>
|
||||
plugin.name.includes(search) || plugin.title.includes(search)),
|
||||
[plugins, search],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const getPlugins = async () => {
|
||||
setIsLoading(true)
|
||||
const plugins = await fetch.get<Plugin[]>('/admin/plugins/market/list')
|
||||
setPlugins(() => plugins)
|
||||
setTotalPages(Math.ceil(plugins.length / 10))
|
||||
setIsLoading(false)
|
||||
}
|
||||
getPlugins()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const getPlugins = async () => {
|
||||
setIsLoading(true);
|
||||
const plugins = await fetch.get<Plugin[]>('/admin/plugins/market/list');
|
||||
setPlugins(() => plugins);
|
||||
setTotalPages(Math.ceil(plugins.length / 10));
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const search = event.target.value
|
||||
setSearch(search)
|
||||
setPage(1)
|
||||
void getPlugins();
|
||||
}, []);
|
||||
|
||||
const searchedPlugins = plugins.filter(
|
||||
(plugin) => plugin.name.includes(search) || plugin.title.includes(search),
|
||||
)
|
||||
setTotalPages(Math.ceil(searchedPlugins.length / 10))
|
||||
}
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const search = event.target.value;
|
||||
setSearch(search);
|
||||
setPage(1);
|
||||
|
||||
const handleInstall = async (plugin: Plugin, index: number) => {
|
||||
setInstallings((installings) => {
|
||||
installings.add(plugin.name)
|
||||
})
|
||||
const searchedPlugins = plugins.filter(plugin => plugin.name.includes(search) || plugin.title.includes(search));
|
||||
setTotalPages(Math.ceil(searchedPlugins.length / 10));
|
||||
};
|
||||
|
||||
const {
|
||||
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 {
|
||||
showModal({
|
||||
mode: 'alert',
|
||||
children: (
|
||||
<div>
|
||||
<p>{message}</p>
|
||||
<ul>
|
||||
{data.reason.map((t, i) => (
|
||||
<li key={i}>{t}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
const handleInstall = async (plugin: Plugin, index: number) => {
|
||||
setInstallings(installings => {
|
||||
installings.add(plugin.name);
|
||||
});
|
||||
|
||||
setInstallings((installings) => {
|
||||
installings.delete(plugin.name)
|
||||
})
|
||||
}
|
||||
const {
|
||||
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) => {
|
||||
try {
|
||||
await showModal({
|
||||
text: t('admin.confirmUpdate', {
|
||||
plugin: plugin.title,
|
||||
old: plugin.installed,
|
||||
new: plugin.version,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
setInstallings(installings => {
|
||||
installings.delete(plugin.name);
|
||||
});
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('vendor.datatable.search')}
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="card-body">
|
||||
<Loading />
|
||||
</div>
|
||||
) : searchedPlugins.length === 0 ? (
|
||||
<div className="card-body text-center">{t('general.noResult')}</div>
|
||||
) : (
|
||||
<div className="card-body table-responsive p-0">
|
||||
<table className="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.pluginTitle')}</th>
|
||||
<th>{t('admin.pluginDescription')}</th>
|
||||
<th>{t('admin.pluginAuthor')}</th>
|
||||
<th>{t('admin.pluginVersion')}</th>
|
||||
<th>{t('admin.pluginDependencies')}</th>
|
||||
<th>{t('admin.operationsTitle')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedPlugins.map((plugin, i) => (
|
||||
<Row
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
isInstalling={installings.has(plugin.name)}
|
||||
onInstall={() => handleInstall(plugin, (page - 1) * 10 + i)}
|
||||
onUpdate={() => handleUpdate(plugin, (page - 1) * 10 + i)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="card-footer">
|
||||
<div className="float-right">
|
||||
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const pagedPlugins = searchedPlugins.slice((page - 1) * 10, page * 10);
|
||||
|
||||
return (
|
||||
<div className='card'>
|
||||
<div className='card-header'>
|
||||
<input
|
||||
type='text'
|
||||
className='form-control'
|
||||
placeholder={t('vendor.datatable.search')}
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
{isLoading
|
||||
? (
|
||||
<div className='card-body'>
|
||||
<Loading/>
|
||||
</div>
|
||||
)
|
||||
: searchedPlugins.length === 0
|
||||
? <div className='card-body text-center'>{t('general.noResult')}</div>
|
||||
: (
|
||||
<div className='card-body table-responsive p-0'>
|
||||
<table className='table table-striped'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.pluginTitle')}</th>
|
||||
<th>{t('admin.pluginDescription')}</th>
|
||||
<th>{t('admin.pluginAuthor')}</th>
|
||||
<th>{t('admin.pluginVersion')}</th>
|
||||
<th>{t('admin.pluginDependencies')}</th>
|
||||
<th>{t('admin.operationsTitle')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedPlugins.map((plugin, i) => (
|
||||
<Row
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
isInstalling={installings.has(plugin.name)}
|
||||
onInstall={async () => handleInstall(plugin, ((page - 1) * 10) + i)}
|
||||
onUpdate={async () => handleUpdate(plugin, ((page - 1) * 10) + i)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className='card-footer'>
|
||||
<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 = {
|
||||
name: string
|
||||
version: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
installed: string | false
|
||||
can_update?: boolean
|
||||
dependencies: {
|
||||
all: Record<string, string>
|
||||
unsatisfied: Record<string, string>
|
||||
}
|
||||
}
|
||||
name: string;
|
||||
version: string;
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
installed: string | false;
|
||||
can_update?: boolean;
|
||||
dependencies: {
|
||||
all: Record<string, string>;
|
||||
unsatisfied: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react'
|
||||
import styled from '@emotion/styled'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import type { Texture } from '@/scripts/types'
|
||||
import { Report, Status } from './types'
|
||||
|
||||
import type {Texture} from '@/scripts/types';
|
||||
import {t} from '@/scripts/i18n';
|
||||
import styled from '@emotion/styled';
|
||||
import {type Report, Status} from './types';
|
||||
|
||||
const Card = styled.div`
|
||||
width: 240px;
|
||||
|
|
@ -27,121 +27,131 @@ const Card = styled.div`
|
|||
margin: 2.5px 0;
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
report: Report
|
||||
onClick(texture: Texture | null): void
|
||||
onBan(): void
|
||||
onDelete(): void
|
||||
onReject(): void
|
||||
}
|
||||
type Props = {
|
||||
readonly report: Report;
|
||||
onClick: (texture: Texture | undefined) => void;
|
||||
onBan: () => void;
|
||||
onDelete: () => void;
|
||||
onReject: () => void;
|
||||
};
|
||||
|
||||
const ImageBox: React.FC<Props> = (props) => {
|
||||
const { report } = props
|
||||
const preview = `${blessing.base_url}/preview/${report.tid}?height=150`
|
||||
const previewPNG = `${preview}&png`
|
||||
const ImageBox: React.FC<Props> = props => {
|
||||
const {report} = props;
|
||||
const preview = `${blessing.base_url}/preview/${report.tid}?height=150`;
|
||||
const previewPNG = `${preview}&png`;
|
||||
|
||||
const handleImageClick = () => props.onClick(report.texture)
|
||||
const handleImageClick = () => {
|
||||
props.onClick(report.texture);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="card mr-3 mb-3">
|
||||
<div className="card-header">
|
||||
<b>
|
||||
{t('skinlib.show.uploader')}
|
||||
{': '}
|
||||
</b>
|
||||
<span className="mr-1">{report.texture_uploader?.nickname}</span>
|
||||
(UID: {report.uploader})
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<picture>
|
||||
<source srcSet={preview} type="image/webp" />
|
||||
<img
|
||||
src={previewPNG}
|
||||
alt={report.tid.toString()}
|
||||
className="card-img-top"
|
||||
onClick={handleImageClick}
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<div className="d-flex justify-content-between">
|
||||
<div>
|
||||
{report.status === Status.Pending ? (
|
||||
<span className="badge bg-warning">{t('report.status.0')}</span>
|
||||
) : report.status === Status.Resolved ? (
|
||||
<span className="badge bg-success">{t('report.status.1')}</span>
|
||||
) : (
|
||||
<span className="badge bg-danger">{t('report.status.2')}</span>
|
||||
)}
|
||||
<span className="badge bg-info ml-1">TID: {report.tid}</span>
|
||||
</div>
|
||||
<div className="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={`${blessing.base_url}/skinlib/show/${report.tid}`}
|
||||
className="dropdown-item"
|
||||
target="_blank"
|
||||
>
|
||||
<i className="fas fa-share-square mr-2"></i>
|
||||
{t('user.viewInSkinlib')}
|
||||
</a>
|
||||
<a href="#" className="dropdown-item" onClick={props.onBan}>
|
||||
<i className="fas fa-user-slash mr-2"></i>
|
||||
{t('report.ban')}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="dropdown-item dropdown-item-danger"
|
||||
onClick={props.onDelete}
|
||||
>
|
||||
<i className="fas fa-trash mr-2"></i>
|
||||
{t('skinlib.show.delete-texture')}
|
||||
</a>
|
||||
<a href="#" className="dropdown-item" onClick={props.onReject}>
|
||||
<i className="fas fa-thumbs-down mr-2"></i>
|
||||
{t('report.reject')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<b>
|
||||
{t('report.reporter')}
|
||||
{': '}
|
||||
</b>
|
||||
<span className="mr-1">{report.informer?.nickname}</span>
|
||||
(UID: {report.reporter})
|
||||
</div>
|
||||
<details>
|
||||
<summary className="text-truncate">
|
||||
<b>
|
||||
{t('report.reason')}
|
||||
{': '}
|
||||
</b>
|
||||
{report.reason}
|
||||
</summary>
|
||||
<div>{report.reason}</div>
|
||||
<div>
|
||||
<small>
|
||||
{t('report.time')}
|
||||
{': '}
|
||||
{report.report_at}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Card className='card mr-3 mb-3'>
|
||||
<div className='card-header'>
|
||||
<b>
|
||||
{t('skinlib.show.uploader')}
|
||||
{': '}
|
||||
</b>
|
||||
<span className='mr-1'>{report.texture_uploader?.nickname}</span>
|
||||
(UID:
|
||||
{' '}
|
||||
{report.uploader}
|
||||
)
|
||||
</div>
|
||||
<div className='card-body'>
|
||||
<picture>
|
||||
<source srcSet={preview} type='image/webp'/>
|
||||
<img
|
||||
src={previewPNG}
|
||||
alt={report.tid.toString()}
|
||||
className='card-img-top'
|
||||
onClick={handleImageClick}
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
<div className='card-footer'>
|
||||
<div className='d-flex justify-content-between'>
|
||||
<div>
|
||||
{report.status === Status.Pending
|
||||
? <span className='badge bg-warning'>{t('report.status.0')}</span>
|
||||
: report.status === Status.Resolved
|
||||
? <span className='badge bg-success'>{t('report.status.1')}</span>
|
||||
: <span className='badge bg-danger'>{t('report.status.2')}</span>}
|
||||
<span className='badge bg-info ml-1'>
|
||||
TID:
|
||||
{report.tid}
|
||||
</span>
|
||||
</div>
|
||||
<div className='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={`${blessing.base_url}/skinlib/show/${report.tid}`}
|
||||
className='dropdown-item'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<i className='fas fa-share-square mr-2'/>
|
||||
{t('user.viewInSkinlib')}
|
||||
</a>
|
||||
<a href='#' className='dropdown-item' onClick={props.onBan}>
|
||||
<i className='fas fa-user-slash mr-2'/>
|
||||
{t('report.ban')}
|
||||
</a>
|
||||
<a
|
||||
href='#'
|
||||
className='dropdown-item dropdown-item-danger'
|
||||
onClick={props.onDelete}
|
||||
>
|
||||
<i className='fas fa-trash mr-2'/>
|
||||
{t('skinlib.show.delete-texture')}
|
||||
</a>
|
||||
<a href='#' className='dropdown-item' onClick={props.onReject}>
|
||||
<i className='fas fa-thumbs-down mr-2'/>
|
||||
{t('report.reject')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<b>
|
||||
{t('report.reporter')}
|
||||
{': '}
|
||||
</b>
|
||||
<span className='mr-1'>{report.informer?.nickname}</span>
|
||||
(UID:
|
||||
{' '}
|
||||
{report.reporter}
|
||||
)
|
||||
</div>
|
||||
<details>
|
||||
<summary className='text-truncate'>
|
||||
<b>
|
||||
{t('report.reason')}
|
||||
{': '}
|
||||
</b>
|
||||
{report.reason}
|
||||
</summary>
|
||||
<div>{report.reason}</div>
|
||||
<div>
|
||||
<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 { hot } from 'react-hot-loader/root'
|
||||
import { useImmer } from 'use-immer'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { Paginator, Texture, TextureType } from '@/scripts/types'
|
||||
import { toast, showModal } from '@/scripts/notify'
|
||||
import Loading from '@/components/Loading'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import ViewerSkeleton from '@/components/ViewerSkeleton'
|
||||
import type { Report, Status } from './types'
|
||||
import ImageBox from './ImageBox'
|
||||
import type {Report, Status} from './types';
|
||||
import Loading from '@/components/Loading';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import ViewerSkeleton from '@/components/ViewerSkeleton';
|
||||
import {t} from '@/scripts/i18n';
|
||||
import * as fetch from '@/scripts/net';
|
||||
import {showModal, toast} from '@/scripts/notify';
|
||||
import {type Paginator, type Texture, TextureType} from '@/scripts/types';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useImmer} from 'use-immer';
|
||||
import ImageBox from './ImageBox';
|
||||
|
||||
const Previewer = React.lazy(() => import('@/components/Viewer'))
|
||||
const Previewer = React.lazy(async () => import('@/components/Viewer'));
|
||||
|
||||
const ReportsManagement: React.FC = () => {
|
||||
const [reports, setReports] = useImmer<Report[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [query, setQuery] = useState('status:0 sort:-report_at')
|
||||
const [viewingTexture, setViewingTexture] = useState<Texture | null>(null)
|
||||
function ReportsManagement() {
|
||||
const [reports, setReports] = useImmer<Report[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [query, setQuery] = useState('status:0 sort:-report_at');
|
||||
const [viewingTexture, setViewingTexture] = useState<Texture | undefined>(null);
|
||||
|
||||
const getReports = async () => {
|
||||
setIsLoading(true)
|
||||
const { data, last_page }: Paginator<Report> = await fetch.get(
|
||||
'/admin/reports/list',
|
||||
{
|
||||
q: query,
|
||||
page,
|
||||
},
|
||||
)
|
||||
setTotalPages(last_page)
|
||||
setReports(() => data)
|
||||
setIsLoading(false)
|
||||
}
|
||||
const getReports = async () => {
|
||||
setIsLoading(true);
|
||||
const {data, last_page}: Paginator<Report> = await fetch.get(
|
||||
'/admin/reports/list',
|
||||
{
|
||||
q: query,
|
||||
page: page.toString(),
|
||||
},
|
||||
);
|
||||
setTotalPages(last_page);
|
||||
setReports(() => data);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getReports()
|
||||
}, [page])
|
||||
useEffect(() => {
|
||||
getReports();
|
||||
}, [page]);
|
||||
|
||||
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value)
|
||||
}
|
||||
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value);
|
||||
};
|
||||
|
||||
const handleSubmitQuery = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
getReports()
|
||||
}
|
||||
const handleSubmitQuery = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
getReports();
|
||||
};
|
||||
|
||||
const handleProceedReport = async (
|
||||
report: Report,
|
||||
index: number,
|
||||
action: 'ban' | 'delete' | 'reject',
|
||||
) => {
|
||||
type Ok = { code: 0; message: string; data: { status: Status } }
|
||||
type Err = { code: 1; message: string }
|
||||
const resp = await fetch.put<Ok | Err>(`/admin/reports/${report.id}`, {
|
||||
action,
|
||||
})
|
||||
const handleProceedReport = async (
|
||||
report: Report,
|
||||
index: number,
|
||||
action: 'ban' | 'delete' | 'reject',
|
||||
) => {
|
||||
type Ok = {code: 0; message: string; data: {status: Status}};
|
||||
type Error_ = {code: 1; message: string};
|
||||
const resp = await fetch.put<Ok | Error_>(`/admin/reports/${report.id}`, {
|
||||
action,
|
||||
});
|
||||
|
||||
if (resp.code === 0) {
|
||||
toast.success(resp.message)
|
||||
setReports((reports) => {
|
||||
reports[index]!.status = resp.data.status
|
||||
})
|
||||
toast.success(resp.message);
|
||||
setReports(reports => {
|
||||
reports[index].status = resp.data.status;
|
||||
});
|
||||
} else {
|
||||
toast.error(resp.message)
|
||||
toast.error(resp.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (report: Report, index: number) => {
|
||||
try {
|
||||
await showModal({
|
||||
text: t('skinlib.deleteNotice'),
|
||||
okButtonType: 'danger',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const handleDelete = async (report: Report, index: number) => {
|
||||
try {
|
||||
await showModal({
|
||||
text: t('skinlib.deleteNotice'),
|
||||
okButtonType: 'danger',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
handleProceedReport(report, index, 'delete')
|
||||
}
|
||||
handleProceedReport(report, index, 'delete');
|
||||
};
|
||||
|
||||
const textureUrl =
|
||||
viewingTexture && `${blessing.base_url}/textures/${viewingTexture.hash}`
|
||||
const textureUrl
|
||||
= viewingTexture && `${blessing.base_url}/textures/${viewingTexture.hash}`;
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-lg-8">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<form className="input-group" onSubmit={handleSubmitQuery}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
title={t('vendor.datatable.search')}
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
/>
|
||||
<div className="input-group-append">
|
||||
<button className="btn btn-primary" type="submit">
|
||||
{t('vendor.datatable.search')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="card-body">
|
||||
<Loading />
|
||||
</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="card-body text-center">{t('general.noResult')}</div>
|
||||
) : (
|
||||
<div className="card-body d-flex flex-wrap">
|
||||
{reports.map((report, i) => (
|
||||
<ImageBox
|
||||
key={report.id}
|
||||
report={report}
|
||||
onClick={setViewingTexture}
|
||||
onBan={() => handleProceedReport(report, i, 'ban')}
|
||||
onDelete={() => handleDelete(report, i)}
|
||||
onReject={() => handleProceedReport(report, i, 'reject')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-footer">
|
||||
<div className="float-right">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-4">
|
||||
<React.Suspense fallback={<ViewerSkeleton />}>
|
||||
<Previewer
|
||||
{...{
|
||||
[viewingTexture?.type === TextureType.Cape
|
||||
? TextureType.Cape
|
||||
: 'skin']: textureUrl,
|
||||
}}
|
||||
isAlex={viewingTexture?.type === TextureType.Alex}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className='row'>
|
||||
<div className='col-lg-8'>
|
||||
<div className='card'>
|
||||
<div className='card-header'>
|
||||
<form className='input-group' onSubmit={handleSubmitQuery}>
|
||||
<input
|
||||
type='text'
|
||||
className='form-control'
|
||||
title={t('vendor.datatable.search')}
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
/>
|
||||
<div className='input-group-append'>
|
||||
<button className='btn btn-primary' type='submit'>
|
||||
{t('vendor.datatable.search')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{isLoading
|
||||
? (
|
||||
<div className='card-body'>
|
||||
<Loading/>
|
||||
</div>
|
||||
)
|
||||
: reports.length === 0
|
||||
? <div className='card-body text-center'>{t('general.noResult')}</div>
|
||||
: (
|
||||
<div className='card-body d-flex flex-wrap'>
|
||||
{reports.map((report, i) => (
|
||||
<ImageBox
|
||||
key={report.id}
|
||||
report={report}
|
||||
onClick={setViewingTexture}
|
||||
onBan={async () => handleProceedReport(report, i, 'ban')}
|
||||
onDelete={async () => handleDelete(report, i)}
|
||||
onReject={async () => handleProceedReport(report, i, 'reject')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className='card-footer'>
|
||||
<div className='float-right'>
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='col-lg-4'>
|
||||
<React.Suspense fallback={<ViewerSkeleton/>}>
|
||||
<Previewer
|
||||
{...{
|
||||
[viewingTexture?.type === TextureType.Cape
|
||||
? TextureType.Cape
|
||||
: 'skin']: textureUrl,
|
||||
}}
|
||||
isAlex={viewingTexture?.type === TextureType.Alex}
|
||||
/>
|
||||
</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 {
|
||||
Pending = 0,
|
||||
Resolved = 1,
|
||||
Rejected = 2,
|
||||
Pending = 0,
|
||||
Resolved = 1,
|
||||
Rejected = 2,
|
||||
}
|
||||
|
||||
export type Report = {
|
||||
id: number
|
||||
tid: number
|
||||
texture: Texture | null
|
||||
uploader: number
|
||||
texture_uploader: User | null
|
||||
reporter: number
|
||||
informer: User | null
|
||||
reason: string
|
||||
status: Status
|
||||
report_at: string
|
||||
}
|
||||
id: number;
|
||||
tid: number;
|
||||
texture: Texture | undefined;
|
||||
uploader: number;
|
||||
texture_uploader: User | undefined;
|
||||
reporter: number;
|
||||
informer: User | undefined;
|
||||
reason: string;
|
||||
status: Status;
|
||||
report_at: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,47 +1,50 @@
|
|||
import styled from '@emotion/styled'
|
||||
import React from 'react'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import type { Line } from './types'
|
||||
import type {Line} from './types';
|
||||
import {t} from '@/scripts/i18n';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const Group = styled.td`
|
||||
width: 15%;
|
||||
`
|
||||
`;
|
||||
const Key = styled.td`
|
||||
width: 20%;
|
||||
`
|
||||
`;
|
||||
const Operations = styled.td`
|
||||
width: 25%;
|
||||
`
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
line: Line
|
||||
onEdit(line: Line): void
|
||||
onRemove(line: Line): void
|
||||
}
|
||||
type Props = {
|
||||
readonly line: Line;
|
||||
onEdit: (line: Line) => void;
|
||||
onRemove: (line: Line) => void;
|
||||
};
|
||||
|
||||
const Row: React.FC<Props> = (props) => {
|
||||
const { line, onEdit, onRemove } = props
|
||||
const text = line.text[blessing.locale]
|
||||
const Row: React.FC<Props> = props => {
|
||||
const {line, onEdit, onRemove} = props;
|
||||
const text = line.text[blessing.locale];
|
||||
|
||||
const handleEditClick = () => onEdit(line)
|
||||
const handleEditClick = () => {
|
||||
onEdit(line);
|
||||
};
|
||||
|
||||
const handleRemoveClick = () => onRemove(line)
|
||||
const handleRemoveClick = () => {
|
||||
onRemove(line);
|
||||
};
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<Group>{line.group}</Group>
|
||||
<Key>{line.key}</Key>
|
||||
<td>{text || t('admin.i18n.empty')}</td>
|
||||
<Operations>
|
||||
<button className="btn btn-default mr-2" onClick={handleEditClick}>
|
||||
{t('admin.i18n.modify')}
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={handleRemoveClick}>
|
||||
{t('admin.i18n.delete')}
|
||||
</button>
|
||||
</Operations>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<tr>
|
||||
<Group>{line.group}</Group>
|
||||
<Key>{line.key}</Key>
|
||||
<td>{text || t('admin.i18n.empty')}</td>
|
||||
<Operations>
|
||||
<button className='btn btn-default mr-2' onClick={handleEditClick}>
|
||||
{t('admin.i18n.modify')}
|
||||
</button>
|
||||
<button className='btn btn-danger' onClick={handleRemoveClick}>
|
||||
{t('admin.i18n.delete')}
|
||||
</button>
|
||||
</Operations>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export default Row
|
||||
export default Row;
|
||||
|
|
|
|||
|
|
@ -1,120 +1,122 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { hot } from 'react-hot-loader/root'
|
||||
import { useImmer } from 'use-immer'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { showModal, toast } from '@/scripts/notify'
|
||||
import type { Paginator } from '@/scripts/types'
|
||||
import Loading from '@/components/Loading'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import type { Line } from './types'
|
||||
import Row from './Row'
|
||||
import type {Paginator} from '@/scripts/types';
|
||||
import type {Line} from './types';
|
||||
import Loading from '@/components/Loading';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import {t} from '@/scripts/i18n';
|
||||
import * as fetch from '@/scripts/net';
|
||||
import {showModal, toast} from '@/scripts/notify';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useImmer} from 'use-immer';
|
||||
import Row from './Row';
|
||||
|
||||
const Translations: React.FC = () => {
|
||||
const [lines, setLines] = useImmer<Line[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
function Translations() {
|
||||
const [lines, setLines] = useImmer<Line[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const getLines = async () => {
|
||||
setIsLoading(true)
|
||||
const result = await fetch.get<Paginator<Line>>('/admin/i18n/list', {
|
||||
page,
|
||||
})
|
||||
setLines(() => result.data)
|
||||
setTotalPages(result.last_page)
|
||||
setIsLoading(false)
|
||||
}
|
||||
getLines()
|
||||
}, [page])
|
||||
useEffect(() => {
|
||||
const getLines = async () => {
|
||||
setIsLoading(true);
|
||||
const result = await fetch.get<Paginator<Line>>('/admin/i18n/list', {
|
||||
page: page.toString(),
|
||||
});
|
||||
setLines(() => result.data);
|
||||
setTotalPages(result.last_page);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleEdit = async (line: Line, index: number) => {
|
||||
let text: string
|
||||
try {
|
||||
const { value } = await showModal({
|
||||
mode: 'prompt',
|
||||
text: t('admin.i18n.updating'),
|
||||
input: line.text[blessing.locale],
|
||||
})
|
||||
text = value
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
getLines();
|
||||
}, [page]);
|
||||
|
||||
const { code, message } = await fetch.put<fetch.ResponseBody>(
|
||||
`/admin/i18n/${line.id}`,
|
||||
{ text },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
setLines((lines) => {
|
||||
lines[index]!.text[blessing.locale] = text
|
||||
})
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
const handleEdit = async (line: Line, index: number) => {
|
||||
let text: string;
|
||||
try {
|
||||
const {value} = await showModal({
|
||||
mode: 'prompt',
|
||||
text: t('admin.i18n.updating'),
|
||||
input: line.text[blessing.locale],
|
||||
});
|
||||
text = value;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleRemove = async (line: Line) => {
|
||||
try {
|
||||
await showModal({
|
||||
text: t('admin.i18n.confirmDelete'),
|
||||
okButtonType: 'danger',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const {code, message} = await fetch.put<fetch.ResponseBody>(
|
||||
`/admin/i18n/${line.id}`,
|
||||
{text},
|
||||
);
|
||||
if (code === 0) {
|
||||
toast.success(message);
|
||||
setLines(lines => {
|
||||
lines[index].text[blessing.locale] = text;
|
||||
});
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const { message } = await fetch.del(`/admin/i18n/${line.id}`)
|
||||
toast.success(message)
|
||||
const { id } = line
|
||||
setLines((lines) => lines.filter((line) => line.id !== id))
|
||||
}
|
||||
const handleRemove = async (line: Line) => {
|
||||
try {
|
||||
await showModal({
|
||||
text: t('admin.i18n.confirmDelete'),
|
||||
okButtonType: 'danger',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card-body p-0">
|
||||
<table className="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.i18n.group')}</th>
|
||||
<th>{t('admin.i18n.key')}</th>
|
||||
<th>{t('admin.i18n.text')}</th>
|
||||
<th>{t('admin.operationsTitle')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td className="text-center" colSpan={4}>
|
||||
<Loading />
|
||||
</td>
|
||||
</tr>
|
||||
) : lines.length === 0 ? (
|
||||
<tr>
|
||||
<td className="text-center" colSpan={4}>
|
||||
{t('general.noResult')}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
lines.map((line, i) => (
|
||||
<Row
|
||||
key={line.id}
|
||||
line={line}
|
||||
onEdit={(line) => handleEdit(line, i)}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="card-footer d-flex flex-row-reverse">
|
||||
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
const {message} = await fetch.del(`/admin/i18n/${line.id}`);
|
||||
toast.success(String(message));
|
||||
const {id} = line;
|
||||
setLines(lines => lines.filter(line => line.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='card-body p-0'>
|
||||
<table className='table table-striped'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.i18n.group')}</th>
|
||||
<th>{t('admin.i18n.key')}</th>
|
||||
<th>{t('admin.i18n.text')}</th>
|
||||
<th>{t('admin.operationsTitle')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? (
|
||||
<tr>
|
||||
<td className='text-center' colSpan={4}>
|
||||
<Loading/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: lines.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td className='text-center' colSpan={4}>
|
||||
{t('general.noResult')}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: lines.map((line, i) => (
|
||||
<Row
|
||||
key={line.id}
|
||||
line={line}
|
||||
onEdit={async line => handleEdit(line, i)}
|
||||
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 = {
|
||||
id: number
|
||||
group: string
|
||||
key: string
|
||||
text: Record<string, string>
|
||||
}
|
||||
id: number;
|
||||
group: string;
|
||||
key: string;
|
||||
text: Record<string, string>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,24 +1,22 @@
|
|||
import { post, ResponseBody } from '../../scripts/net'
|
||||
import { showModal } from '../../scripts/notify'
|
||||
import { t } from '../../scripts/i18n'
|
||||
import {t} from '../../scripts/i18n';
|
||||
import {post, type ResponseBody} from '../../scripts/net';
|
||||
import {showModal} from '../../scripts/notify';
|
||||
|
||||
export default async function handler(event: MouseEvent) {
|
||||
const button = event.target as HTMLButtonElement
|
||||
button.disabled = true
|
||||
const button = event.target as HTMLButtonElement;
|
||||
button.disabled = true;
|
||||
|
||||
const text = button.textContent
|
||||
button.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t(
|
||||
'admin.downloading',
|
||||
)}`
|
||||
const text = button.textContent;
|
||||
button.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t('admin.downloading')}`;
|
||||
|
||||
const { code, message }: ResponseBody = await post('/admin/update/download')
|
||||
button.textContent = text
|
||||
button.disabled = false
|
||||
await showModal({ mode: 'alert', text: message })
|
||||
if (code === 0) {
|
||||
location.href = '/'
|
||||
}
|
||||
const {code, message}: ResponseBody = await post('/admin/update/download');
|
||||
button.textContent = text;
|
||||
button.disabled = false;
|
||||
await showModal({mode: 'alert', text: message});
|
||||
if (code === 0) {
|
||||
location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
const button = document.querySelector<HTMLButtonElement>('#update')
|
||||
button?.addEventListener('click', handler)
|
||||
const button = document.querySelector<HTMLButtonElement>('#update');
|
||||
button?.addEventListener('click', handler);
|
||||
|
|
|
|||
|
|
@ -1,164 +1,167 @@
|
|||
import React from 'react'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import type { User } from '@/scripts/types'
|
||||
import { Box, Icon, InfoTable } from './styles'
|
||||
|
||||
import type {User} from '@/scripts/types';
|
||||
import {t} from '@/scripts/i18n';
|
||||
import clsx from 'clsx';
|
||||
import {Box, Icon, InfoTable} from './styles';
|
||||
import {
|
||||
humanizePermission,
|
||||
verificationStatusText,
|
||||
canModifyUser,
|
||||
canModifyPermission,
|
||||
} from './utils'
|
||||
import clsx from 'clsx'
|
||||
canModifyPermission,
|
||||
canModifyUser,
|
||||
humanizePermission,
|
||||
verificationStatusText,
|
||||
} from './utils';
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
currentUser: User
|
||||
onEmailChange(): void
|
||||
onNicknameChange(): void
|
||||
onScoreChange(): void
|
||||
onPermissionChange(): void
|
||||
onVerificationToggle(): void
|
||||
onPasswordChange(): void
|
||||
onDelete(): void
|
||||
}
|
||||
type Props = {
|
||||
readonly user: User;
|
||||
readonly currentUser: User;
|
||||
onEmailChange: () => void;
|
||||
onNicknameChange: () => void;
|
||||
onScoreChange: () => void;
|
||||
onPermissionChange: () => void;
|
||||
onVerificationToggle: () => void;
|
||||
onPasswordChange: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const Card: React.FC<Props> = (props) => {
|
||||
const { user, currentUser } = props
|
||||
const Card: React.FC<Props> = 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 avatarPNG = `${avatar}?png`
|
||||
const canModify = canModifyUser(user, currentUser)
|
||||
const avatar = `${blessing.base_url}/avatar/user/${user.uid}`;
|
||||
const avatarPNG = `${avatar}?png`;
|
||||
const canModify = canModifyUser(user, currentUser);
|
||||
|
||||
return (
|
||||
<Box className={clsx('info-box', { 'bg-gray-dark': isDarkMode })}>
|
||||
<Icon py>
|
||||
<picture>
|
||||
<source srcSet={avatar} type="image/webp" />
|
||||
<img className="bs-avatar" src={avatarPNG} />
|
||||
</picture>
|
||||
</Icon>
|
||||
<div className="info-box-content">
|
||||
<div className="row">
|
||||
<div className="col-10">
|
||||
<b>{user.nickname}</b>
|
||||
</div>
|
||||
<div className="col-2">
|
||||
{canModify && (
|
||||
<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={props.onEmailChange}
|
||||
>
|
||||
<i className="fas fa-at mr-2"></i>
|
||||
{t('admin.changeEmail')}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="dropdown-item"
|
||||
onClick={props.onNicknameChange}
|
||||
>
|
||||
<i className="fas fa-signature mr-2"></i>
|
||||
{t('admin.changeNickName')}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="dropdown-item"
|
||||
onClick={props.onPasswordChange}
|
||||
>
|
||||
<i className="fas fa-asterisk mr-2"></i>
|
||||
{t('admin.changePassword')}
|
||||
</a>
|
||||
<div className="dropdown-divider"></div>
|
||||
<a
|
||||
href="#"
|
||||
className="dropdown-item"
|
||||
onClick={props.onScoreChange}
|
||||
>
|
||||
<i className="fas fa-coins mr-2"></i>
|
||||
{t('admin.changeScore')}
|
||||
</a>
|
||||
{canModifyPermission(user, currentUser) && (
|
||||
<a
|
||||
href="#"
|
||||
className="dropdown-item"
|
||||
onClick={props.onPermissionChange}
|
||||
>
|
||||
<i className="fas fa-user-secret mr-2"></i>
|
||||
{t('admin.changePermission')}
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="#"
|
||||
className="dropdown-item"
|
||||
onClick={props.onVerificationToggle}
|
||||
>
|
||||
<i className="fas fa-user-check mr-2"></i>
|
||||
{t('admin.toggleVerification')}
|
||||
</a>
|
||||
<div className="dropdown-divider"></div>
|
||||
{canModify && user.uid !== currentUser.uid && (
|
||||
<a
|
||||
href="#"
|
||||
className="dropdown-item dropdown-item-danger"
|
||||
onClick={props.onDelete}
|
||||
>
|
||||
<i className="fas fa-trash mr-2"></i>
|
||||
{t('admin.deleteUser')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>UID: {user.uid}</div>
|
||||
<div>
|
||||
{t('general.user.email')}
|
||||
{': '}
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
<InfoTable className="row m-2 border-top border-bottom">
|
||||
<div className="col-sm-4 py-1 text-center">
|
||||
<b className="d-block">{t('general.user.score')}</b>
|
||||
<span className="d-block py-1">{user.score}</span>
|
||||
</div>
|
||||
<div className="col-sm-4 py-1 text-center">
|
||||
<b className="d-block">{t('admin.permission')}</b>
|
||||
<span className="d-block py-1">
|
||||
{humanizePermission(user.permission)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-sm-4 py-1 text-center">
|
||||
<b className="d-block">{t('admin.verification')}</b>
|
||||
<span className="d-block py-1">
|
||||
{verificationStatusText(user.verified)}
|
||||
</span>
|
||||
</div>
|
||||
</InfoTable>
|
||||
<div>
|
||||
<small className="text-gray">
|
||||
{t('general.user.register-at')}
|
||||
{': '}
|
||||
{user.register_at}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Box className={clsx('info-box', {'bg-gray-dark': isDarkMode})}>
|
||||
<Icon py>
|
||||
<picture>
|
||||
<source srcSet={avatar} type='image/webp'/>
|
||||
<img className='bs-avatar' src={avatarPNG}/>
|
||||
</picture>
|
||||
</Icon>
|
||||
<div className='info-box-content'>
|
||||
<div className='row'>
|
||||
<div className='col-10'>
|
||||
<b>{user.nickname}</b>
|
||||
</div>
|
||||
<div className='col-2'>
|
||||
{canModify && (
|
||||
<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={props.onEmailChange}
|
||||
>
|
||||
<i className='fas fa-at mr-2'/>
|
||||
{t('admin.changeEmail')}
|
||||
</a>
|
||||
<a
|
||||
href='#'
|
||||
className='dropdown-item'
|
||||
onClick={props.onNicknameChange}
|
||||
>
|
||||
<i className='fas fa-signature mr-2'/>
|
||||
{t('admin.changeNickName')}
|
||||
</a>
|
||||
<a
|
||||
href='#'
|
||||
className='dropdown-item'
|
||||
onClick={props.onPasswordChange}
|
||||
>
|
||||
<i className='fas fa-asterisk mr-2'/>
|
||||
{t('admin.changePassword')}
|
||||
</a>
|
||||
<div className='dropdown-divider'/>
|
||||
<a
|
||||
href='#'
|
||||
className='dropdown-item'
|
||||
onClick={props.onScoreChange}
|
||||
>
|
||||
<i className='fas fa-coins mr-2'/>
|
||||
{t('admin.changeScore')}
|
||||
</a>
|
||||
{canModifyPermission(user, currentUser) && (
|
||||
<a
|
||||
href='#'
|
||||
className='dropdown-item'
|
||||
onClick={props.onPermissionChange}
|
||||
>
|
||||
<i className='fas fa-user-secret mr-2'/>
|
||||
{t('admin.changePermission')}
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href='#'
|
||||
className='dropdown-item'
|
||||
onClick={props.onVerificationToggle}
|
||||
>
|
||||
<i className='fas fa-user-check mr-2'/>
|
||||
{t('admin.toggleVerification')}
|
||||
</a>
|
||||
<div className='dropdown-divider'/>
|
||||
{canModify && user.uid !== currentUser.uid && (
|
||||
<a
|
||||
href='#'
|
||||
className='dropdown-item dropdown-item-danger'
|
||||
onClick={props.onDelete}
|
||||
>
|
||||
<i className='fas fa-trash mr-2'/>
|
||||
{t('admin.deleteUser')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
UID:
|
||||
{user.uid}
|
||||
</div>
|
||||
<div>
|
||||
{t('general.user.email')}
|
||||
{': '}
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
<InfoTable className='row m-2 border-top border-bottom'>
|
||||
<div className='col-sm-4 py-1 text-center'>
|
||||
<b className='d-block'>{t('general.user.score')}</b>
|
||||
<span className='d-block py-1'>{user.score}</span>
|
||||
</div>
|
||||
<div className='col-sm-4 py-1 text-center'>
|
||||
<b className='d-block'>{t('admin.permission')}</b>
|
||||
<span className='d-block py-1'>
|
||||
{humanizePermission(user.permission)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='col-sm-4 py-1 text-center'>
|
||||
<b className='d-block'>{t('admin.verification')}</b>
|
||||
<span className='d-block py-1'>
|
||||
{verificationStatusText(user.verified)}
|
||||
</span>
|
||||
</div>
|
||||
</InfoTable>
|
||||
<div>
|
||||
<small className='text-gray'>
|
||||
{t('general.user.register-at')}
|
||||
{': '}
|
||||
{user.register_at}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card
|
||||
export default Card;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import styled from '@emotion/styled'
|
||||
import { lessThan, Breakpoint } from '@/styles/breakpoints'
|
||||
import {Breakpoint, lessThan} from '@/styles/breakpoints';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
|
|
@ -17,6 +17,6 @@ const Header = styled.div`
|
|||
margin: 7px 0 0 0;
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export default Header
|
||||
export default Header;
|
||||
|
|
|
|||
|
|
@ -1,61 +1,60 @@
|
|||
import React from 'react'
|
||||
import styled from '@emotion/styled'
|
||||
import Skeleton from 'react-loading-skeleton'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import { Box, Icon, InfoTable } from './styles'
|
||||
import clsx from 'clsx'
|
||||
import {t} from '@/scripts/i18n';
|
||||
import styled from '@emotion/styled';
|
||||
import clsx from 'clsx';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import {Box, Icon, InfoTable} from './styles';
|
||||
|
||||
const ShrinkedSkeleton = styled(Skeleton)<{ width?: string }>`
|
||||
width: ${(props) => props.width};
|
||||
`
|
||||
const ShrinkedSkeleton = styled(Skeleton)<{width?: string}>`
|
||||
width: ${props => props.width};
|
||||
`;
|
||||
|
||||
const isDarkMode = document.body.classList.contains('dark-mode')
|
||||
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||
|
||||
const LoadingCard: React.FC = () => (
|
||||
<Box className={clsx('info-box', { 'bg-gray-dark': isDarkMode })}>
|
||||
<Icon>
|
||||
<Skeleton circle height={50} width={50} />
|
||||
</Icon>
|
||||
<div className="info-box-content">
|
||||
<div className="row">
|
||||
<div className="col-10">
|
||||
<Skeleton width="140px" />
|
||||
</div>
|
||||
<div className="col-2"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<Skeleton width="140px" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton width="140px" />
|
||||
</div>
|
||||
<InfoTable className="row m-2 border-top border-bottom">
|
||||
<div className="col-sm-4 py-1 text-center">
|
||||
<b className="d-block">{t('general.user.score')}</b>
|
||||
<span className="d-block py-1">
|
||||
<ShrinkedSkeleton width="30%" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-sm-4 py-1 text-center">
|
||||
<b className="d-block">{t('admin.permission')}</b>
|
||||
<span className="d-block py-1">
|
||||
<ShrinkedSkeleton width="30%" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-sm-4 py-1 text-center">
|
||||
<b className="d-block">{t('admin.verification')}</b>
|
||||
<span className="d-block py-1">
|
||||
<ShrinkedSkeleton width="30%" />
|
||||
</span>
|
||||
</div>
|
||||
</InfoTable>
|
||||
<div>
|
||||
<Skeleton width="180px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export default LoadingCard
|
||||
export default function LoadingCard() {
|
||||
return (
|
||||
<Box className={clsx('info-box', {'bg-gray-dark': isDarkMode})}>
|
||||
<Icon>
|
||||
<Skeleton circle height={50} width={50}/>
|
||||
</Icon>
|
||||
<div className='info-box-content'>
|
||||
<div className='row'>
|
||||
<div className='col-10'>
|
||||
<Skeleton width='140px'/>
|
||||
</div>
|
||||
<div className='col-2'/>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<Skeleton width='140px'/>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton width='140px'/>
|
||||
</div>
|
||||
<InfoTable className='row m-2 border-top border-bottom'>
|
||||
<div className='col-sm-4 py-1 text-center'>
|
||||
<b className='d-block'>{t('general.user.score')}</b>
|
||||
<span className='d-block py-1'>
|
||||
<ShrinkedSkeleton width='30%'/>
|
||||
</span>
|
||||
</div>
|
||||
<div className='col-sm-4 py-1 text-center'>
|
||||
<b className='d-block'>{t('admin.permission')}</b>
|
||||
<span className='d-block py-1'>
|
||||
<ShrinkedSkeleton width='30%'/>
|
||||
</span>
|
||||
</div>
|
||||
<div className='col-sm-4 py-1 text-center'>
|
||||
<b className='d-block'>{t('admin.verification')}</b>
|
||||
<span className='d-block py-1'>
|
||||
<ShrinkedSkeleton width='30%'/>
|
||||
</span>
|
||||
</div>
|
||||
</InfoTable>
|
||||
<div>
|
||||
<Skeleton width='180px'/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user