Compare commits

...

13 Commits
dev ... cleanup

Author SHA1 Message Date
Zephyr Lykos
baefaf51cc
cleanup: wip 6.2 2025-01-19 22:10:05 +08:00
Zephyr Lykos
d48d332c83
cleanup: wip 6.1 2025-01-19 15:25:15 +08:00
Zephyr Lykos
590ed9ce73
cleanup: wip 6 2025-01-19 14:16:55 +08:00
Zephyr Lykos
ea5be502b3
chore: update deps, use bs-community/TwigBridge 2025-01-18 17:04:51 +08:00
Zephyr Lykos
e50cc6ee28
cleanup: upgrade deps & misc 2024-04-13 16:49:28 +08:00
Zephyr Lykos
00639b7bc2
cleanup: wip 5
Use admin-lte@next, @angular-package/spectre.css
2024-03-05 11:20:32 +08:00
Zephyr Lykos
e7b4111d2b
cleanup: wip 4 2024-03-05 10:38:01 +08:00
Zephyr Lykos
643f73c752
fix: do not do type-only imports of React 2024-03-05 10:28:07 +08:00
Zephyr Lykos
e6665a3977
cleanup: wip 3.2
Move postcss config to package.json
2024-02-27 10:13:09 +08:00
Zephyr Lykos
9524a234cf
cleanup: wip 3.1
mostly misc cleanups
2024-02-24 23:01:32 +08:00
Zephyr Lykos
af2c13a8b4
cleanup: wip part 3
twig + vite integration done
needs https://github.com/rcrowe/TwigBridge/pull/435
hmr: to be tested
2024-02-24 00:53:19 +08:00
Zephyr Lykos
ae71d36c7f
cleanup: wip part 2
We have a working vite config now
2024-02-23 18:24:34 +08:00
Zephyr Lykos
2b196a95a8
cleanup: wip part 1 2024-02-23 16:58:50 +08:00
310 changed files with 21194 additions and 25106 deletions

View File

@ -9,10 +9,9 @@ services:
# Update 'VARIANT' to pick a version of PHP version: 8, 8.1, 8.0, 7, 7.4 # Update 'VARIANT' to pick a version of PHP version: 8, 8.1, 8.0, 7, 7.4
# Append -bullseye or -buster to pin to an OS version. # Append -bullseye or -buster to pin to an OS version.
# Use -bullseye variants on local arm64/Apple Silicon. # Use -bullseye variants on local arm64/Apple Silicon.
VARIANT: "8-bullseye" VARIANT: 8-bullseye
# Optional Node.js version # Optional Node.js version
NODE_VERSION: "lts/*" NODE_VERSION: 'lts/*'
volumes: volumes:
- ..:/workspace:cached - ..:/workspace:cached

View File

@ -4,10 +4,11 @@ root = true
[*] [*]
charset = utf-8 charset = utf-8
end_of_line = lf end_of_line = lf
indent_size = 2 indent_size = 4
indent_style = space indent_style = tab
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
[*.{php,md,ps1,Dockerfile}] [*.{json,yaml,yml}]
indent_size = 4 indent_style = space
indent_size = 2

View File

@ -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

View File

@ -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

View File

@ -13,28 +13,15 @@ tasks:
php artisan serve --host=0.0.0.0 php artisan serve --host=0.0.0.0
- command: gp ports await 8080 && gp preview $(gp url 8000) - command: gp ports await 8080 && gp preview $(gp url 8000)
github:
prebuilds:
# enable for the master/default branch (defaults to true)
master: true
# enable for all branches in this repo (defaults to false)
branches: false
# enable for pull requests coming from this repo (defaults to true)
pullRequests: true
# add a check to pull requests (defaults to true)
addCheck: true
# add a "Review in Gitpod" button as a comment to pull requests (defaults to false)
addComment: false
vscode: vscode:
extensions: extensions:
- 'editorconfig.editorconfig' - editorconfig.editorconfig
- 'eamodio.gitlens' - eamodio.gitlens
- 'bmewburn.vscode-intelephense-client' - bmewburn.vscode-intelephense-client
- 'esbenp.prettier-vscode' - esbenp.prettier-vscode
- 'jpoissonnier.vscode-styled-components' - jpoissonnier.vscode-styled-components
- 'mblode.twig-language-2' - mblode.twig-language-2
- 'felixfbecker.php-debug' - felixfbecker.php-debug
ports: ports:
- port: 8080 - port: 8080

View File

@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn pretty-quick --staged

72
.vscode/launch.json vendored
View File

@ -1,38 +1,38 @@
{ {
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"name": "Launch Jest Tests", "name": "Launch Jest Tests",
"program": "${workspaceFolder}/node_modules/.bin/jest", "program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["${file}"], "args": ["${file}"],
"internalConsoleOptions": "openOnSessionStart", "internalConsoleOptions": "openOnSessionStart",
"skipFiles": [ "skipFiles": [
"<node_internals>/**" "<node_internals>/**"
] ]
}, },
{ {
"type": "php", "type": "php",
"request": "launch", "request": "launch",
"name": "Launch with XDebug", "name": "Launch with XDebug",
"ignore": [ "ignore": [
"**/vendor/**/*.php" "**/vendor/**/*.php"
] ]
}, },
{ {
"type": "firefox", "type": "firefox",
"request": "launch", "request": "launch",
"reAttach": true, "reAttach": true,
"name": "Launch with Firefox Debugger", "name": "Launch with Firefox Debugger",
"url": "http://localhost/", "url": "http://localhost/",
"webRoot": "${workspaceFolder}", "webRoot": "${workspaceFolder}",
"pathMappings": [ "pathMappings": [
{ {
"url": "webpack:///", "url": "webpack:///",
"path": "${workspaceFolder}/" "path": "${workspaceFolder}/"
} }
] ]
} }
] ]
} }

View File

@ -41,7 +41,6 @@ class UserMenuComposer
['label' => trans('general.admin-panel'), 'link' => route('admin.view')], ['label' => trans('general.admin-panel'), 'link' => route('admin.view')],
['label' => trans('general.user-manage'), 'link' => route('admin.users.view')], ['label' => trans('general.user-manage'), 'link' => route('admin.users.view')],
['label' => trans('general.report-manage'), 'link' => route('admin.reports.view')], ['label' => trans('general.report-manage'), 'link' => route('admin.reports.view')],
['label' => 'Web CLI', 'link' => '#launch-cli'],
); );
} }
$menuItems = $this->filter->apply('user_menu', $menuItems, [$user]); $menuItems = $this->filter->apply('user_menu', $menuItems, [$user]);

View File

@ -29,7 +29,7 @@
"lorisleiva/laravel-search-string": "^1.0", "lorisleiva/laravel-search-string": "^1.0",
"nesbot/carbon": "^2.0", "nesbot/carbon": "^2.0",
"nunomaduro/collision": "^7.0", "nunomaduro/collision": "^7.0",
"rcrowe/twigbridge": "^0.14", "rcrowe/twigbridge": "dev-blessing",
"spatie/laravel-translation-loader": "^2.7", "spatie/laravel-translation-loader": "^2.7",
"symfony/process": "^6.0", "symfony/process": "^6.0",
"symfony/yaml": "^5.0", "symfony/yaml": "^5.0",
@ -81,10 +81,14 @@
] ]
} }
}, },
"repositories": { "repositories": [
"packagist": { {
"type": "vcs",
"url": "https://github.com/bs-community/TwigBridge"
},
{
"type": "composer", "type": "composer",
"url": "https://packagist.org/" "url": "https://packagist.org/"
} }
} ]
} }

4743
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -123,6 +123,7 @@ return [
// 'TwigBridge\Extension\Laravel\Form', // 'TwigBridge\Extension\Laravel\Form',
// 'TwigBridge\Extension\Laravel\Html', // 'TwigBridge\Extension\Laravel\Html',
'TwigBridge\Extension\Laravel\Vite',
// 'TwigBridge\Extension\Laravel\Legacy\Facades', // 'TwigBridge\Extension\Laravel\Legacy\Facades',
], ],
@ -153,7 +154,8 @@ return [
| in order to be marked as safe. | in order to be marked as safe.
| |
*/ */
'facades' => [], 'facades' => [
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

11
eslint.config.js Normal file
View File

@ -0,0 +1,11 @@
import {configBuilder} from '@mochaa/eslintrc';
export default configBuilder({
ignores: [
'public/',
'vendor/',
'vendor/',
'plugins/',
'storage/',
],
});

View File

@ -1,162 +1,90 @@
{ {
"name": "blessing-skin-server", "name": "blessing-skin-server",
"type": "module",
"version": "6.0.2", "version": "6.0.2",
"private": true,
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"description": "A web application brings your custom skins back in offline Minecraft servers.", "description": "A web application brings your custom skins back in offline Minecraft servers.",
"author": "printempw",
"license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/bs-community/blessing-skin-server" "url": "https://github.com/bs-community/blessing-skin-server"
}, },
"author": "printempw",
"license": "MIT",
"private": true,
"scripts": { "scripts": {
"dev": "webpack serve", "build": "vite build",
"build": "webpack --env production --progress",
"lint": "eslint --ext=ts -f=beauty .",
"fmt": "prettier --write resources/assets tools webpack.config.ts",
"fmt:check": "prettier --check resources/assets tools webpack.config.ts",
"type:check": "tsc -p . --noEmit && tsc -p ./resources/assets/tests --noEmit",
"test": "jest",
"build:urls": "ts-node tools/generateUrls.ts", "build:urls": "ts-node tools/generateUrls.ts",
"prepare": "husky install" "dev": "vite",
"lint": "eslint .",
"test": "vitest"
}, },
"browserslist": [
"Firefox ESR",
"iOS >= 12.5",
"Chrome >= 87"
],
"dependencies": { "dependencies": {
"@emotion/react": "^11.0.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.0.0", "@emotion/styled": "^11.14.0",
"@fortawesome/fontawesome-free": "^6.3.0", "@fortawesome/fontawesome-free": "^6.7.2",
"@hot-loader/react-dom": "^17.0.0", "@tweenjs/tween.js": "^25.0.0",
"@tweenjs/tween.js": "^18.5.0", "admin-lte": "4.0.0-beta3",
"admin-lte": "^3.2.0", "bootstrap": "^5.3.3",
"blessing-skin-shell": "^0.3.4", "clsx": "^2.1.1",
"bootstrap": "^4.6.1", "downshift": "^9.0.8",
"cac": "6.6.1", "echarts": "^5.6.0",
"cli-spinners": "^2.5.0", "immer": "^10.1.1",
"clsx": "^1.1.1",
"echarts": "^5.1.2",
"events": "^3.2.0",
"immer": "^7.0.4",
"jquery": "^3.6.0", "jquery": "^3.6.0",
"lodash.debounce": "^4.0.8", "lodash-es": "^4.0.8",
"nanoid": "^3.1.9", "nanoid": "^5.0.9",
"prompts": "^2.4.0", "prompts": "^2.4.0",
"react": "^17.0.1", "react": "^18.0.0",
"react-autosuggest": "^10.0.2", "react-dom": "^18.0.0",
"react-dom": "^17.0.1",
"react-draggable": "^4.4.2", "react-draggable": "^4.4.2",
"react-hot-loader": "^4.12.21", "react-loading-skeleton": "^3.5.0",
"react-loading-skeleton": "^2.1.1", "react-use": "^17.6.0",
"react-use": "^17.4.0",
"reaptcha": "^1.7.2", "reaptcha": "^1.7.2",
"rxjs": "^6.5.5", "rxjs": "^7.8.1",
"skinview-utils": "^0.5.5", "skinview-utils": "^0.7.1",
"skinview3d": "^3.0.0-alpha.1", "skinview3d": "^3.1.0",
"spectre.css": "^0.5.8", "spectre.css": "github:angular-package/spectre.css",
"use-immer": "^0.4.2", "use-immer": "^0.11.0"
"xterm": "^4.6.0",
"xterm-addon-fit": "^0.4.0"
}, },
"devDependencies": { "devDependencies": {
"@gplane/tsconfig": "^4.2.0", "@eslint-react/eslint-plugin": "^1.23.2",
"@testing-library/jest-dom": "^5.11.10", "@mochaa/eslintrc": "^0.1.12",
"@testing-library/react": "^11.2.6", "@testing-library/jest-dom": "^6.6.3",
"@types/bootstrap": "^4.3.3", "@testing-library/react": "^16.2.0",
"@types/css-minimizer-webpack-plugin": "^1.1.0", "@tsconfig/vite-react": "^3.4.0",
"@types/jest": "^26.0.23", "@types/bootstrap": "^5.2.10",
"@types/jquery": "^3.5.13", "@types/jquery": "^3.5.32",
"@types/js-yaml": "^3.12.4", "@types/js-yaml": "^4.0.9",
"@types/lodash.debounce": "^4.0.6", "@types/lodash-es": "^4.0.6",
"@types/mini-css-extract-plugin": "^1.2.1",
"@types/prompts": "^2.0.9", "@types/prompts": "^2.0.9",
"@types/react": "^16.9.35", "@types/react": "^18",
"@types/react-autosuggest": "^9.3.14", "@types/react-dom": "^18",
"@types/react-dom": "^16.9.8",
"@types/tween.js": "^18.5.0", "@types/tween.js": "^18.5.0",
"@types/webpack-dev-server": "^3.11.0", "@vitejs/plugin-react-swc": "^3.7.2",
"@typescript-eslint/eslint-plugin": "^3.6.0", "autoprefixer": "^10.4.20",
"@typescript-eslint/parser": "^3.6.0", "browserslist": "^4.24.4",
"autoprefixer": "^10.2.6", "browserslist-to-esbuild": "^2.1.1",
"css-loader": "^5.2.6", "eslint": "^9.18.0",
"css-minimizer-webpack-plugin": "^3.0.1", "eslint-plugin-react-hooks": "^5.1.0",
"eslint": "^7.4.0", "eslint-plugin-react-refresh": "^0.4.18",
"eslint-formatter-beauty": "^3.0.0", "js-yaml": "^4.1.0",
"eslint-plugin-react-hooks": "^4.3.0", "laravel-vite-plugin": "^1.1.1",
"html-webpack-plugin": "^5.3.1", "postcss": "^8.5.1",
"husky": "^7.0.4", "sass": "^1.83.4",
"jest": "^27.0.4", "typescript": "^5.7.3",
"jest-extended": "^0.11.5", "vite": "^6.0.7",
"js-yaml": "^3.13.1", "vitest": "^3.0.2"
"mini-css-extract-plugin": "^1.6.0",
"postcss": "^8.3.0",
"postcss-loader": "^5.3.0",
"prettier": "^2.3.0",
"pretty-quick": "^3.1.3",
"style-loader": "^2.0.0",
"ts-jest": "^27.0.2",
"ts-loader": "^9.2.2",
"ts-node": "^10.0.0",
"typescript": "^4.3.2",
"webpack": "^5.38.1",
"webpack-cli": "^4.7.0",
"webpack-dev-server": "^3.11.2"
}, },
"resolutions": { "resolutions": {
"kleur": "^4.1.3" "kleur": "^4.1.3"
}, },
"browserslist": [ "postcss": {
"> 1%", "plugins": {
"not dead", "autoprefixer": {}
"not ie 11",
"Chrome > 52"
],
"prettier": {
"printWidth": 80,
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2
},
"jest": {
"preset": "ts-jest",
"resetMocks": true,
"testEnvironment": "jsdom",
"moduleFileExtensions": [
"js",
"ts",
"tsx",
"json",
"node"
],
"moduleNameMapper": {
"\\.css$": "<rootDir>/resources/assets/tests/__mocks__/style.ts",
"\\.(png|webp)$": "<rootDir>/resources/assets/tests/__mocks__/file.ts",
"^@/(.*)$": "<rootDir>/resources/assets/src/$1"
},
"setupFilesAfterEnv": [
"<rootDir>/resources/assets/tests/setup.ts"
],
"coveragePathIgnorePatterns": [
"/node_modules/",
"<rootDir>/resources/assets/src/styles",
"<rootDir>/resources/assets/src/scripts/extra.ts",
"<rootDir>/resources/assets/src/scripts/urls.ts",
"<rootDir>/resources/assets/tests/setup",
"<rootDir>/resources/assets/tests/utils",
"<rootDir>/resources/assets/tests/scripts/cli/stdio"
],
"testMatch": [
"<rootDir>/resources/assets/tests/**/*.test.ts",
"<rootDir>/resources/assets/tests/**/*.test.tsx"
],
"testPathIgnorePatterns": [
"/node_modules/",
"<rootDir>/resources/assets/tests/(views|components)/.*\\.ts$"
],
"maxWorkers": "50%",
"globals": {
"ts-jest": {
"tsconfig": "<rootDir>/resources/assets/tests/tsconfig.json",
"isolatedModules": true
}
} }
} }
} }

View File

@ -1,5 +0,0 @@
module.exports = {
plugins: [
require('autoprefixer'),
],
}

2
public/.gitignore vendored
View File

@ -1 +1,3 @@
app/ app/
build/
hot

View 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';

View File

@ -1,28 +1,28 @@
import React from 'react' type AlertType = 'success' | 'info' | 'warning' | 'danger';
type AlertType = 'success' | 'info' | 'warning' | 'danger'
const icons = new Map<AlertType, string>([ const icons = new Map<AlertType, string>([
['success', 'check'], ['success', 'check'],
['info', 'info'], ['info', 'info'],
['warning', 'exclamation-triangle'], ['warning', 'exclamation-triangle'],
['danger', 'times-circle'], ['danger', 'times-circle'],
]) ]);
interface Props { type Props = {
type: AlertType readonly type: AlertType;
} readonly children?: React.ReactNode;
};
const Alert: React.FC<Props> = (props) => { const Alert: React.FC<Props> = ({type, children}) => {
const { type } = props const icon = icons.get(type);
const icon = icons.get(type)
return props.children ? ( return children === ''
<div className={`alert alert-${type}`}> ? null
<i className={`icon fas fa-${icon}`}></i> : (
{props.children} <div className={`alert alert-${type}`}>
</div> <i className={`icon fas fa-${icon}`}/>
) : null {children}
} </div>
);
};
export default Alert export default Alert;

View File

@ -1,14 +1,12 @@
import React from 'react' type Props = {
readonly title?: string;
readonly onClick: React.MouseEventHandler<HTMLAnchorElement>;
};
interface Props { const ButtonEdit: React.FC<Props> = ({title, onClick}) => (
title?: string <a href='#' title={title} className='ml-2' onClick={onClick}>
onClick: React.MouseEventHandler<HTMLAnchorElement> <i className='fas fa-edit'/>
} </a>
);
const ButtonEdit: React.FC<Props> = (props) => ( export default ButtonEdit;
<a href="#" title={props.title} className="ml-2" onClick={props.onClick}>
<i className="fas fa-edit"></i>
</a>
)
export default ButtonEdit

View File

@ -1,103 +1,108 @@
/** @jsxImportSource @emotion/react */ import {emit, on} from '@/scripts/event';
import * as React from 'react' import {t} from '@/scripts/i18n';
import Reaptcha from 'reaptcha' import * as cssUtils from '@/styles/utils';
import { emit, on } from '@/scripts/event' import React from 'react';
import { t } from '@/scripts/i18n' import Reaptcha from 'reaptcha';
import * as cssUtils from '@/styles/utils'
const eventId = Symbol() const eventId = Symbol('EventId');
type State = { type State = {
value: string value: string;
time: number time: number;
sitekey: string sitekey: string;
invisible: boolean invisible: boolean;
} };
class Captcha extends React.Component<Record<string, unknown>, State> { class Captcha extends React.Component<Record<string, unknown>, State> {
state: State state: State;
ref: React.MutableRefObject<Reaptcha | null> // eslint-disable-next-line ts/no-restricted-types
ref: React.RefObject<Reaptcha | null>;
constructor(props: Record<string, unknown>) { constructor(props: Record<string, unknown>) {
super(props) super(props);
this.state = { this.state = {
value: '', value: '',
time: Date.now(), time: Date.now(),
sitekey: blessing.extra.recaptcha, sitekey: blessing.extra.recaptcha as string,
invisible: blessing.extra.invisible, invisible: blessing.extra.invisible as boolean,
} };
this.ref = React.createRef() this.ref = React.createRef<Reaptcha>();
} }
execute = async () => { // eslint-disable-next-line react/no-unused-class-component-members
const recaptcha = this.ref.current async execute() {
if (recaptcha && this.state.invisible) { const recaptcha = this.ref.current;
return new Promise<string>((resolve) => { if (recaptcha && this.state.invisible) {
const off = on(eventId, (value: string) => { return new Promise<string>(resolve => {
resolve(value) const off = on(eventId, (value: string) => {
off() resolve(value);
}) off();
recaptcha.execute() });
}) void recaptcha.execute();
} });
return this.state.value }
}
reset = () => { return this.state.value;
const recaptcha = this.ref.current }
if (recaptcha) {
recaptcha.reset()
} else {
this.setState({ time: Date.now() })
}
}
handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => { // eslint-disable-next-line react/no-unused-class-component-members
this.setState({ value: event.target.value }) reset() {
} const recaptcha = this.ref.current;
if (recaptcha) {
void recaptcha.reset();
} else {
this.setState({time: Date.now()});
}
}
handleVerify = (value: string) => { handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
emit(eventId, value) this.setState({value: event.target.value});
this.setState({ value }) };
}
handleRefresh = () => { handleVerify = (value: string) => {
this.setState({ time: Date.now() }) emit(eventId, value);
} this.setState({value});
};
render() { handleRefresh = () => {
return this.state.sitekey ? ( this.setState({time: Date.now()});
<div className="mb-2"> };
<Reaptcha
ref={this.ref} render() {
sitekey={this.state.sitekey} return this.state.sitekey
size={this.state.invisible ? 'invisible' : 'normal'} ? (
onVerify={this.handleVerify} <div className='mb-2'>
/> <Reaptcha
</div> ref={this.ref}
) : ( sitekey={this.state.sitekey}
<div className="d-flex"> size={this.state.invisible ? 'invisible' : 'normal'}
<div className="form-group mb-3 mr-2"> onVerify={this.handleVerify}
<input />
type="text" </div>
className="form-control" )
placeholder={t('auth.captcha')} : (
required <div className='d-flex'>
value={this.state.value} <div className='form-group mb-3 mr-2'>
onChange={this.handleValueChange} <input
/> required
</div> type='text'
<img className='form-control'
src={`${blessing.base_url}/auth/captcha?v=${this.state.time}`} placeholder={t('auth.captcha')}
alt={t('auth.captcha')} value={this.state.value}
css={cssUtils.pointerCursor} onChange={this.handleValueChange}
height={34} />
title={t('auth.change-captcha')} </div>
onClick={this.handleRefresh} <img
/> src={`${blessing.base_url}/auth/captcha?v=${this.state.time}`}
</div> alt={t('auth.captcha')}
) css={cssUtils.pointerCursor}
} height={34}
title={t('auth.change-captcha')}
onClick={this.handleRefresh}
/>
</div>
);
}
} }
export default Captcha export default Captcha;

View File

@ -1,27 +1,27 @@
import React, { useState } from 'react' import * as fetch from '@/scripts/net';
import * as fetch from '@/scripts/net' import {useState} from 'react';
interface Props { type Props = {
initMode: boolean readonly initMode: boolean;
} };
const DarkModeButton: React.FC<Props> = ({ initMode }) => { const DarkModeButton: React.FC<Props> = ({initMode}) => {
const [darkMode, setDarkMode] = useState(initMode) const [darkMode, setDarkMode] = useState(initMode);
const icon = darkMode ? 'moon' : 'sun' const icon = darkMode ? 'moon' : 'sun';
const handleClick = async () => { const handleClick = async () => {
setDarkMode((value) => !value) setDarkMode(value => !value);
await fetch.put('/user/dark-mode') await fetch.put('/user/dark-mode');
document.body.classList.toggle('dark-mode') document.body.classList.toggle('dark-mode');
} };
return ( return (
<a className="nav-link" href="#" role="button" onClick={handleClick}> <a className='nav-link' href='#' role='button' onClick={handleClick}>
<i className={`fas fa-${icon}`}></i> <i className={`fas fa-${icon}`}/>
</a> </a>
) );
} };
export default DarkModeButton export default DarkModeButton;

View File

@ -1,89 +1,67 @@
/** @jsxImportSource @emotion/react */ import {emit} from '@/scripts/event';
import React, { useState, useEffect } from 'react' import {pointerCursor} from '@/styles/utils';
import Autosuggest from 'react-autosuggest' import {css} from '@emotion/react';
import { css } from '@emotion/react' import clsx from 'clsx';
import { emit } from '@/scripts/event' import {useCombobox} from 'downshift';
import { pointerCursor } from '@/styles/utils' import {useEffect, useState} from 'react';
const styles = css` const styles = css`
.dropdown-menu li { .dropdown-menu li {
${pointerCursor} ${pointerCursor}
} }
` `;
const domainNames = new Set(['qq.com', '163.com', 'gmail.com', 'hotmail.com']) const domainNames = new Set(['qq.com', '163.com', 'gmail.com', 'hotmail.com']);
type Props = Omit<Autosuggest.InputProps<string>, 'onChange'> & { type Props = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
onChange(value: string): void onChange: (value: string) => void;
} };
const EmailSuggestion: React.FC<Props> = (props) => { const EmailSuggestion: React.FC<Props> = props => {
const [suggestions, setSuggestions] = useState<string[]>([]) useEffect(() => {
emit('emailDomainsSuggestion', domainNames);
}, []);
const [inputItems, setInputItems] = useState<string[]>([]);
useEffect(() => { const {
emit('emailDomainsSuggestion', domainNames) isOpen,
}, []) getLabelProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
} = useCombobox({
items: inputItems,
onInputValueChange({inputValue: value}) {
setInputItems([...domainNames].map(name => `${value.split('@')[0]}@${name}`));
if (value.length === 0 || value.includes('@')) {
setInputItems([]);
}
const handleSuggestionsFetchRequested: Autosuggest.SuggestionsFetchRequested = const {onChange} = props;
({ value }) => { onChange(value);
const segments = value.split('@') },
setSuggestions([...domainNames].map((name) => `${segments[0]}@${name}`)) });
}
const handleSuggestionsClearRequested = () => { return (
setSuggestions([]) <div>
} <div className='input-group'>
<input className='form-control' {...{...props, onChange: undefined}} {...getInputProps()}/>
<div className='input-group-text' {...getLabelProps()}>
<i className='fas fa-envelope'/>
</div>
</div>
<div className='mb-3 dropdown' css={styles}>
<ul className={clsx('dropdown-menu', isOpen && inputItems.length > 0 && 'show')} {...getMenuProps()}>
{isOpen && inputItems.length > 0 && inputItems.map((item, index) => (
<li key={`${item}`} className={clsx('dropdown-item', {active: index === highlightedIndex})} {...getItemProps({item, index})}>
{item}
</li>
))}
</ul>
</div>
</div>
);
};
const shouldRenderSuggestions = (value: string) => { export default EmailSuggestion;
const isSelecting = [...domainNames].some((name) =>
value.endsWith(`@${name}`),
)
return isSelecting || (value.length > 0 && !value.includes('@'))
}
const getSuggestionValue = (value: string) => value
const renderSuggestion = (suggestion: string) => suggestion
const handleChange = (_: React.FormEvent, event: Autosuggest.ChangeEvent) => {
props.onChange(event.newValue)
}
const renderInputComponent = (
props: Omit<Autosuggest.InputProps<string>, 'onChange'>,
) => (
<div className="input-group">
<input className="form-control" {...props} />
<div className="input-group-append">
<div className="input-group-text">
<i className="fas fa-envelope"></i>
</div>
</div>
</div>
)
return (
<div css={styles}>
<Autosuggest
suggestions={suggestions}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
shouldRenderSuggestions={shouldRenderSuggestions}
inputProps={Object.assign({}, props, { onChange: handleChange })}
renderInputComponent={renderInputComponent}
theme={{
container: 'mb-3',
suggestion: 'dropdown-item',
suggestionsContainer: 'dropdown',
suggestionsList: `dropdown-menu ${suggestions.length ? 'show' : ''}`,
suggestionHighlighted: 'active',
}}
/>
</div>
)
}
export default EmailSuggestion

View File

@ -1,53 +1,52 @@
/** @jsxImportSource @emotion/react */ import {t} from '@/scripts/i18n';
import { useRef } from 'react' import {css} from '@emotion/react';
import { css } from '@emotion/react' import {useRef} from 'react';
import { t } from '@/scripts/i18n'
const hideRawBrowseButton = css` const hideRawBrowseButton = css`
::after { ::after {
display: none; display: none;
} }
` `;
interface Props { type Props = {
file: File | null file: File | undefined;
accept?: string accept?: string;
onChange(event: React.ChangeEvent<HTMLInputElement>): void onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
} };
const FileInput: React.FC<Props> = (props) => { const FileInput: React.FC<Props> = props => {
const ref = useRef<HTMLInputElement>(null) const reference = useRef<HTMLInputElement>(null);
const handleClick = () => { const handleClick = () => {
ref.current!.click() reference.current!.click();
} };
return ( return (
<div className="form-group"> <div className='form-group'>
<label htmlFor="select-file">{t('skinlib.upload.select-file')}</label> <label htmlFor='select-file'>{t('skinlib.upload.select-file')}</label>
<div className="input-group"> <div className='input-group'>
<div className="custom-file"> <div className='custom-file'>
<input <input
type="file" ref={reference}
className="custom-file-input" type='file'
id="select-file" className='custom-file-input'
accept={props.accept} id='select-file'
title={t('skinlib.upload.select-file')} accept={props.accept}
ref={ref} title={t('skinlib.upload.select-file')}
onChange={props.onChange} onChange={props.onChange}
/> />
<label className="custom-file-label" css={hideRawBrowseButton}> <label className='custom-file-label' css={hideRawBrowseButton}>
{props.file?.name} {props.file?.name}
</label> </label>
</div> </div>
<div className="input-group-append"> <div className='input-group-append'>
<button className="btn btn-default" onClick={handleClick}> <button className='btn btn-default' onClick={handleClick}>
{t('skinlib.upload.select-file')} {t('skinlib.upload.select-file')}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) );
} };
export default FileInput export default FileInput;

View File

@ -1,9 +1,9 @@
import React from 'react' function Loading() {
return (
<div className='container text-center' title='Loading...'>
<i className='fas fa-sync fa-spin'/>
</div>
);
}
const Loading = () => ( export default Loading;
<div className="container text-center" title="Loading...">
<i className="fas fa-sync fa-spin"></i>
</div>
)
export default Loading

View File

@ -1,165 +1,193 @@
import React, { useState, useEffect, useRef } from 'react' import {Modal as BootstrapModal} from 'bootstrap';
import $ from 'jquery' import clsx from 'clsx';
import 'bootstrap' import {useEffect, useRef, useState} from 'react';
import { t } from '../scripts/i18n' import {t} from '../scripts/i18n';
import ModalHeader from './ModalHeader' import ModalBody, {type Props as BodyProps} from './ModalBody';
import ModalBody from './ModalBody' import ModalFooter, {type Props as FooterProps} from './ModalFooter';
import ModalFooter from './ModalFooter' import ModalHeader, {type Props as HeaderProps} from './ModalHeader';
import type { Props as HeaderProps } from './ModalHeader'
import type { Props as BodyProps } from './ModalBody'
import type { Props as FooterProps } from './ModalFooter'
type BasicOptions = { type BasicOptions = {
mode?: 'alert' | 'confirm' | 'prompt' readonly mode?: 'alert' | 'confirm' | 'prompt';
show?: boolean readonly show?: boolean;
input?: string readonly input?: string;
validator?(value: any): string | boolean | undefined validator?: (value: any) => string | boolean | undefined;
type?: string readonly type?: string;
showHeader?: boolean readonly showHeader?: boolean;
center?: boolean readonly center?: boolean;
children?: React.ReactNode children?: React.ReactNode;
} };
export type ModalOptions = BasicOptions & HeaderProps & BodyProps & FooterProps export type ModalOptions = BasicOptions & HeaderProps & BodyProps & FooterProps;
type Props = { type Props = {
id?: string readonly id?: string;
children?: React.ReactNode readonly children?: React.ReactNode;
footer?: React.ReactNode readonly footer?: React.ReactNode;
onConfirm?(payload: { value: string }): void onConfirm?: (payload: {value: string}) => void;
onDismiss?(): void onDismiss?: () => void;
onClose?(): void onClose?: () => void;
} };
export type ModalResult = { export type ModalResult = {
value: string value: string;
} };
const Modal: React.FC<ModalOptions & Props> = (props) => { const Modal: React.FC<ModalOptions & Props> = props => {
const { const {
mode = 'confirm', mode = 'confirm',
title = t('general.tip'), title = t('general.tip'),
text = '', text = '',
input = '', input = '',
placeholder = '', placeholder = '',
inputType = 'text', inputType = 'text',
inputMode, inputMode,
type = 'default', type = 'default',
showHeader = true, showHeader = true,
center = false, center = false,
okButtonText = t('general.confirm'), okButtonText = t('general.confirm'),
okButtonType = 'primary', okButtonType = 'primary',
cancelButtonText = t('general.cancel'), cancelButtonText = t('general.cancel'),
cancelButtonType = 'secondary', cancelButtonType = 'secondary',
flexFooter = false, flexFooter = false,
} = props footer,
show,
onClose,
onDismiss,
id,
validator,
onConfirm,
children,
choices,
dangerousHTML: html,
} = props;
const [value, setValue] = useState(input) const [value, setValue] = useState(input);
const [valid, setValid] = useState(true) const [valid, setValid] = useState(true);
const [validatorMessage, setValidatorMessage] = useState('') const [validatorMessage, setValidatorMessage] = useState('');
const ref = useRef<HTMLDivElement>(null) const reference = useRef<HTMLDivElement>(null);
const [modal, setModal] = useState<BootstrapModal>();
const { show } = props useEffect(() => {
if (!reference.current) {
return;
}
useEffect(() => { const _modal = new BootstrapModal(reference.current);
if (!show) { setModal(_modal);
return
}
const onHidden = () => props.onClose?.() return () => {
_modal.dispose();
};
}, [reference]);
const el = $(ref.current!) useEffect(() => {
el.on('hidden.bs.modal', onHidden) if (!show) {
return;
}
return () => { const onHidden = () => {
el.off('hidden.bs.modal', onHidden) onClose?.();
} };
}, [show, props.onClose])
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const element = reference.current;
setValue(event.target.value) if (!element) {
} return;
}
const confirm = () => { element.addEventListener('hidden.bs.modal', onHidden);
const { validator } = props
if (typeof validator === 'function') {
const result = validator(value)
if (typeof result === 'string') {
setValidatorMessage(result)
setValid(false)
return
}
}
props.onConfirm?.({ value }) return () => {
$(ref.current!).modal('hide') element.removeEventListener('hidden.bs.modal', onHidden);
};
}, [reference, show, onClose]);
// The "hidden.bs.modal" event can't be trigged automatically when testing. const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
/* istanbul ignore next */ setValue(event.target.value);
if (process.env.NODE_ENV === 'test') { };
$(ref.current!).trigger('hidden.bs.modal')
}
}
const dismiss = () => { const confirm = () => {
props.onDismiss?.() if (typeof validator === 'function') {
$(ref.current!).modal('hide') const result = validator(value);
if (typeof result === 'string') {
setValidatorMessage(result);
setValid(false);
return;
}
}
/* istanbul ignore next */ onConfirm?.({value});
if (process.env.NODE_ENV === 'test') { modal?.hide();
$(ref.current!).trigger('hidden.bs.modal')
}
}
useEffect(() => { // The "hidden.bs.modal" event can't be trigged automatically when testing.
if (show) {
setTimeout(() => $(ref.current!).modal('show'), 50)
}
}, [show])
if (!show) { if (import.meta.env.NODE_ENV === 'test') {
return null $(reference.current!).trigger('hidden.bs.modal');
} }
};
return ( const dismiss = () => {
<div id={props.id} className="modal fade" role="dialog" ref={ref}> onDismiss?.();
<div modal?.hide();
className={`modal-dialog ${center ? 'modal-dialog-centered' : ''}`}
role="document"
>
<div className={`modal-content bg-${type}`}>
<ModalHeader show={showHeader} title={title} onDismiss={dismiss} />
<ModalBody
text={text}
dangerousHTML={props.dangerousHTML}
showInput={mode === 'prompt'}
value={value}
choices={props.choices}
onChange={handleInputChange}
inputType={inputType}
inputMode={inputMode}
placeholder={placeholder}
invalid={!valid}
validatorMessage={validatorMessage}
>
{props.children}
</ModalBody>
<ModalFooter
showCancelButton={mode !== 'alert'}
flexFooter={flexFooter}
okButtonType={okButtonType}
okButtonText={okButtonText}
cancelButtonType={cancelButtonType}
cancelButtonText={cancelButtonText}
onConfirm={confirm}
onDismiss={dismiss}
>
{props.footer}
</ModalFooter>
</div>
</div>
</div>
)
}
export default Modal if (import.meta.env.NODE_ENV === 'test') {
$(reference.current!).trigger('hidden.bs.modal');
}
};
useEffect(() => {
if (show && modal) {
const timeout = setTimeout(() => {
modal.show();
}, 50);
return () => {
clearTimeout(timeout);
};
}
}, [show, modal]);
if (!show) {
return null;
}
return (
<div ref={reference} id={id} className='modal fade' role='dialog'>
<div
className={clsx('modal-dialog', center && 'modal-dialog-centered')}
role='document'
>
<div className={`modal-content bg-${type}`}>
<ModalHeader show={showHeader} title={title} onDismiss={dismiss}/>
<ModalBody
text={text}
dangerousHTML={html}
showInput={mode === 'prompt'}
value={value}
choices={choices}
inputType={inputType}
inputMode={inputMode}
placeholder={placeholder}
invalid={!valid}
validatorMessage={validatorMessage}
onChange={handleInputChange}
>
{children}
</ModalBody>
<ModalFooter
showCancelButton={mode !== 'alert'}
flexFooter={flexFooter}
okButtonType={okButtonType}
okButtonText={okButtonText}
cancelButtonType={cancelButtonType}
cancelButtonText={cancelButtonText}
onConfirm={confirm}
onDismiss={dismiss}
>
{footer}
</ModalFooter>
</div>
</div>
</div>
);
};
export default Modal;

View File

@ -1,29 +1,25 @@
import React from 'react'
import ModalContent from './ModalContent'
import ModalInput from './ModalInput'
import type { Props as ContentProps } from './ModalContent'
import type {
Props as InputProps,
InternalProps as InputInteralProps,
} from './ModalInput'
interface InternalProps { import ModalContent, {type Props as ContentProps} from './ModalContent';
showInput: boolean import ModalInput, {
} type
InternalProps as InputInteralProps,
type
Props as InputProps,
} from './ModalInput';
export type Props = ContentProps & InputProps type InternalProps = {
readonly showInput: boolean;
};
const ModalBody: React.FC<InternalProps & InputInteralProps & Props> = ( export type Props = ContentProps & InputProps;
props,
) => {
return (
<div className="modal-body">
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
{props.children}
</ModalContent>
{props.showInput && <ModalInput {...props} />}
</div>
)
}
export default ModalBody const ModalBody: React.FC<InternalProps & InputInteralProps & Props> = props => (
<div className='modal-body'>
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
{props.children}
</ModalContent>
{props.showInput && <ModalInput {...props}/>}
</div>
);
export default ModalBody;

View File

@ -1,26 +1,29 @@
import React from 'react'
export interface Props { export type Props = {
text?: string readonly text?: string;
dangerousHTML?: string readonly dangerousHTML?: string;
} readonly children?: React.ReactNode;
};
const ModalContent: React.FC<Props> = (props) => { const ModalContent: React.FC<Props> = props => {
if (props.children) { if (props.children) {
return <>{props.children}</> return <>{props.children}</>;
} else if (props.text) { }
return (
<>
{props.text.split(/\r?\n/).map((line, i) => (
<p key={i}>{line}</p>
))}
</>
)
} else if (props.dangerousHTML) {
return <div dangerouslySetInnerHTML={{ __html: props.dangerousHTML }} />
}
return <></> if (props.text) {
} return (
<>
{props.text.split(/\r?\n/).map((line, i) =>
<p key={i}>{line}</p>)}
</>
);
}
export default ModalContent if (props.dangerousHTML) {
return <div dangerouslySetInnerHTML={{__html: props.dangerousHTML}}/>;
}
return <></>;
};
export default ModalContent;

View File

@ -1,49 +1,50 @@
import React from 'react'
export interface Props { export type Props = {
flexFooter?: boolean readonly flexFooter?: boolean;
okButtonText?: string readonly okButtonText?: string;
okButtonType?: string readonly okButtonType?: string;
cancelButtonText?: string readonly cancelButtonText?: string;
cancelButtonType?: string readonly cancelButtonType?: string;
} readonly children?: React.ReactNode;
};
interface InternalProps { type InternalProps = {
showCancelButton: boolean readonly showCancelButton: boolean;
onConfirm?(): void onConfirm?: () => void;
onDismiss?(): void onDismiss?: () => void;
} };
const ModalFooter: React.FC<InternalProps & Props> = (props) => { const ModalFooter: React.FC<InternalProps & Props> = props => {
const classes = ['modal-footer'] const classes = ['modal-footer'];
if (props.flexFooter) { if (props.flexFooter) {
classes.push('d-flex', 'justify-content-between') classes.push('d-flex', 'justify-content-between');
} }
const footerClass = classes.join(' ')
return props.children ? ( const footerClass = classes.join(' ');
<div className={footerClass}>{props.children}</div>
) : (
<div className={footerClass}>
{props.showCancelButton && (
<button
type="button"
className={`btn btn-${props.cancelButtonType}`}
data-dismiss="modal"
onClick={props.onDismiss}
>
{props.cancelButtonText}
</button>
)}
<button
type="button"
className={`btn btn-${props.okButtonType}`}
onClick={props.onConfirm}
>
{props.okButtonText}
</button>
</div>
)
}
export default ModalFooter return props.children
? <div className={footerClass}>{props.children}</div>
: (
<div className={footerClass}>
{props.showCancelButton && (
<button
type='button'
className={`btn btn-${props.cancelButtonType}`}
data-dismiss='modal'
onClick={props.onDismiss}
>
{props.cancelButtonText}
</button>
)}
<button
type='button'
className={`btn btn-${props.okButtonType}`}
onClick={props.onConfirm}
>
{props.okButtonText}
</button>
</div>
);
};
export default ModalFooter;

View File

@ -1,28 +1,27 @@
import React from 'react'
export interface Props { export type Props = {
title?: string readonly title?: string;
} };
interface InternalProps { type InternalProps = {
onDismiss?(): void onDismiss?: () => void;
show?: boolean readonly show?: boolean;
} };
const ModalHeader: React.FC<Props & InternalProps> = (props) => const ModalHeader: React.FC<Props & InternalProps> = ({show, title, onDismiss}) =>
props.show ? ( show
<div className="modal-header"> ? (
<h5 className="modal-title">{props.title}</h5> <div className='modal-header'>
<button <h5 className='modal-title'>{title}</h5>
type="button" <button
className="close" type='button'
data-dismiss="modal" className='btn-close'
aria-label="Close" data-bs-dismiss='modal'
onClick={props.onDismiss} aria-label='Close'
> onClick={onDismiss}
<span aria-hidden>&times;</span> />
</button> </div>
</div> )
) : null : null;
export default ModalHeader export default ModalHeader;

View File

@ -1,58 +1,59 @@
import React, { HTMLAttributes } from 'react'
export interface Props { export type Props = {
inputType?: string readonly inputType?: string;
inputMode?: HTMLAttributes<HTMLInputElement>['inputMode'] readonly inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
choices?: { text: string; value: string }[] readonly choices?: Array<{text: string; value: string}>;
placeholder?: string readonly placeholder?: string;
} };
export interface InternalProps { export type InternalProps = {
value?: string readonly value?: string;
invalid?: boolean readonly invalid?: boolean;
validatorMessage?: string readonly validatorMessage?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement> readonly onChange?: React.ChangeEventHandler<HTMLInputElement>;
} };
const ModalInput: React.FC<InternalProps & Props> = (props) => ( const ModalInput: React.FC<InternalProps & Props> = props => (
<> <>
{props.inputType === 'radios' && props.choices ? ( {props.inputType === 'radios' && props.choices
<> ? (
{props.choices.map((choice) => ( <>
<div key={choice.value}> {props.choices.map(choice => (
<input <div key={choice.value}>
type="radio" <input
name="modal-radios" type='radio'
id={`modal-radio-${choice.value}`} name='modal-radios'
value={choice.value} id={`modal-radio-${choice.value}`}
checked={choice.value === props.value} value={choice.value}
onChange={props.onChange} checked={choice.value === props.value}
/> onChange={props.onChange}
<label htmlFor={`modal-radio-${choice.value}`} className="ml-1"> />
{choice.text} <label htmlFor={`modal-radio-${choice.value}`} className='ml-1'>
</label> {choice.text}
</div> </label>
))} </div>
</> ))}
) : ( </>
<div className="form-group"> )
<input : (
value={props.value} <div className='form-group'>
onChange={props.onChange} <input
type={props.inputType} value={props.value}
inputMode={props.inputMode} type={props.inputType}
className="form-control" inputMode={props.inputMode}
placeholder={props.placeholder} className='form-control'
></input> placeholder={props.placeholder}
</div> onChange={props.onChange}
)} />
{props.invalid && ( </div>
<div className="alert alert-danger"> )}
<i className="icon far fa-times-circle"></i> {props.invalid && (
<span className="ml-1">{props.validatorMessage}</span> <div className='alert alert-danger'>
</div> <i className='icon far fa-times-circle'/>
)} <span className='ml-1'>{props.validatorMessage}</span>
</> </div>
) )}
</>
);
export default ModalInput export default ModalInput;

View File

@ -1,124 +1,122 @@
import React from 'react'
import { t } from '@/scripts/i18n'
import PaginationItem from './PaginationItem'
interface Props { import {t} from '@/scripts/i18n';
page: number import PaginationItem from './PaginationItem';
totalPages: number
onChange(page: number): void | Promise<void> type Props = {
} readonly page: number;
readonly totalPages: number;
onChange: (page: number) => void | Promise<void>;
};
const labels = { const labels = {
prev: '', prev: '',
next: '', next: '',
} };
const Pagination: React.FC<Props> = (props) => { const Pagination: React.FC<Props> = props => {
const { page, totalPages, onChange } = props const {page, totalPages, onChange} = props;
if (totalPages < 1) { if (totalPages < 1) {
return null return null;
} }
return ( return (
<ul className="pagination"> <ul className='pagination'>
<PaginationItem <PaginationItem
title={t('vendor.datatable.prev')} title={t('vendor.datatable.prev')}
disabled={page === 1} disabled={page === 1}
onClick={() => onChange(page - 1)} onClick={async () => onChange(page - 1)}
> >
{labels.prev} {labels.prev}
<span className="d-inline d-sm-none ml-1"> <span className='d-inline d-sm-none ml-1'>
{t('vendor.datatable.prev')} {t('vendor.datatable.prev')}
</span> </span>
</PaginationItem> </PaginationItem>
{totalPages < 8 ? ( {totalPages < 8
Array.from({ length: totalPages }).map((_, i) => ( ? Array.from({length: totalPages}).map((_, i) => (
<PaginationItem <PaginationItem
key={i} key={i}
className="d-none d-sm-block" className='d-none d-sm-block'
active={page === i + 1} active={page === i + 1}
onClick={() => onChange(i + 1)} onClick={async () => onChange(i + 1)}
> >
{i + 1} {i + 1}
</PaginationItem> </PaginationItem>
)) ))
) : ( : (
<> <>
{page < 4 ? ( {page < 4
[1, 2, 3, 4].map((n) => ( ? [1, 2, 3, 4].map(n => (
<PaginationItem <PaginationItem
key={n} key={n}
className="d-none d-sm-block" className='d-none d-sm-block'
active={page === n} active={page === n}
onClick={() => onChange(n)} onClick={async () => onChange(n)}
> >
{n} {n}
</PaginationItem> </PaginationItem>
)) ))
) : ( : (
<PaginationItem <PaginationItem
className="d-none d-sm-block" className='d-none d-sm-block'
onClick={() => onChange(1)} onClick={async () => onChange(1)}
> >
1 1
</PaginationItem> </PaginationItem>
)} )}
<PaginationItem className="d-none d-sm-block" disabled> <PaginationItem disabled className='d-none d-sm-block'>
... ...
</PaginationItem> </PaginationItem>
{page > 3 && page < totalPages - 2 && ( {page > 3 && page < totalPages - 2 && (
<> <>
{[page - 1, page, page + 1].map((n) => ( {[page - 1, page, page + 1].map(n => (
<PaginationItem <PaginationItem
key={n} key={n}
className="d-none d-sm-block" className='d-none d-sm-block'
active={page === n} active={page === n}
onClick={() => onChange(n)} onClick={async () => onChange(n)}
> >
{n} {n}
</PaginationItem> </PaginationItem>
))} ))}
<PaginationItem className="d-none d-sm-block" disabled> <PaginationItem disabled className='d-none d-sm-block'>
... ...
</PaginationItem> </PaginationItem>
</> </>
)} )}
{totalPages - page < 3 ? ( {totalPages - page < 3
[totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map( ? [totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map(n => (
(n) => ( <PaginationItem
<PaginationItem key={n}
key={n} className='d-none d-sm-block'
className="d-none d-sm-block" active={page === n}
active={page === n} onClick={async () => onChange(n)}
onClick={() => onChange(n)} >
> {n}
{n} </PaginationItem>
</PaginationItem> ))
), : (
) <PaginationItem
) : ( className='d-none d-sm-block'
<PaginationItem onClick={async () => onChange(totalPages)}
className="d-none d-sm-block" >
onClick={() => onChange(totalPages)} {totalPages}
> </PaginationItem>
{totalPages} )}
</PaginationItem> </>
)} )}
</> <PaginationItem
)} title={t('vendor.datatable.next')}
<PaginationItem disabled={page === totalPages}
title={t('vendor.datatable.next')} onClick={async () => onChange(page + 1)}
disabled={page === totalPages} >
onClick={() => onChange(page + 1)} <span className='d-inline d-sm-none mr-1'>
> {t('vendor.datatable.next')}
<span className="d-inline d-sm-none mr-1"> </span>
{t('vendor.datatable.next')} {labels.next}
</span> </PaginationItem>
{labels.next} </ul>
</PaginationItem> );
</ul> };
)
}
export default Pagination export default Pagination;

View File

@ -1,39 +1,41 @@
import React from 'react'
interface Props { type Props = {
disabled?: boolean readonly disabled?: boolean;
active?: boolean readonly active?: boolean;
title?: string readonly title?: string;
className?: string readonly className?: string;
onClick?(): void onClick?: () => void;
} readonly children?: React.ReactNode;
};
const PaginationItem: React.FC<Props> = (props) => { const PaginationItem: React.FC<Props> = props => {
const classes = ['page-item'] const classes = ['page-item'];
if (props.active) { if (props.active) {
classes.push('active') classes.push('active');
} }
if (props.disabled) {
classes.push('disabled')
}
if (props.className) {
classes.push(props.className)
}
const handleClick = (event: React.MouseEvent) => { if (props.disabled) {
event.preventDefault() classes.push('disabled');
if (!props.disabled && props.onClick) { }
props.onClick()
}
}
return ( if (props.className) {
<li className={classes.join(' ')} title={props.title} onClick={handleClick}> classes.push(props.className);
<a href="#" className="page-link" aria-disabled={props.disabled}> }
{props.children}
</a>
</li>
)
}
export default PaginationItem const handleClick = (event: React.MouseEvent) => {
event.preventDefault();
if (!props.disabled && props.onClick) {
props.onClick();
}
};
return (
<li className={classes.join(' ')} title={props.title} onClick={handleClick}>
<a href='#' className='page-link' aria-disabled={props.disabled}>
{props.children}
</a>
</li>
);
};
export default PaginationItem;

View File

@ -1,21 +1,21 @@
/** @jsxImportSource @emotion/react */ import {css} from '@emotion/react';
import React, { useState, useEffect } from 'react' import {useEffect, useState} from 'react';
import { css } from '@emotion/react'
export type ToastType = 'success' | 'info' | 'warning' | 'error' export type ToastType = 'success' | 'info' | 'warning' | 'error';
interface Props { type Props = {
type: ToastType readonly type: ToastType;
distance: number readonly distance: number;
onClose(): void | Promise<void> onClose: () => void | Promise<void>;
} readonly children: React.ReactNode;
};
const icons = new Map<ToastType, string>([ const icons = new Map<ToastType, string>([
['success', 'check'], ['success', 'check'],
['info', 'info'], ['info', 'info'],
['warning', 'exclamation-triangle'], ['warning', 'exclamation-triangle'],
['error', 'times-circle'], ['error', 'times-circle'],
]) ]);
const wrapper = css` const wrapper = css`
position: fixed; position: fixed;
@ -24,52 +24,54 @@ const wrapper = css`
z-index: 1050; z-index: 1050;
transition-property: top; transition-property: top;
transition-duration: 0.3s; transition-duration: 0.3s;
` `;
const shadow = css` const shadow = css`
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1); box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
` `;
const Toast: React.FC<Props> = (props) => { const Toast: React.FC<Props> = props => {
const [show, setShow] = useState(false) const [show, setShow] = useState(false);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setShow(true), 100) const timer = setTimeout(() => {
setShow(true);
}, 100);
return () => { return () => {
clearTimeout(timer) clearTimeout(timer);
} };
}, [props.onClose]) }, [props.onClose]);
const type = props.type === 'error' ? 'danger' : props.type const type = props.type === 'error' ? 'danger' : props.type;
const classes = [ const classes = [
`alert alert-${type}`, `alert alert-${type}`,
'd-flex justify-content-between', 'd-flex justify-content-between',
'fade', 'fade',
] ];
if (show) { if (show) {
classes.push('show') classes.push('show');
} }
const role = type === 'success' || type === 'info' ? 'status' : 'alert' const role = type === 'success' || type === 'info' ? 'status' : 'alert';
return ( return (
<div css={wrapper} style={{ top: `${props.distance}px` }}> <div css={wrapper} style={{top: `${props.distance}px`}}>
<div className={classes.join(' ')} css={shadow} role={role}> <div className={classes.join(' ')} css={shadow} role={role}>
<span className="mr-1 d-flex align-items-center"> <span className='mr-1 d-flex align-items-center'>
<i className={`icon fas fa-${icons.get(props.type)}`}></i> <i className={`icon fas fa-${icons.get(props.type)}`}/>
</span> </span>
<span>{props.children}</span> <span>{props.children}</span>
<button <button
type="button" type='button'
className="mr-2 ml-1 close" className='mr-2 ml-1 close'
onClick={props.onClose} onClick={props.onClose}
> >
&times; &times;
</button> </button>
</div> </div>
</div> </div>
) );
} };
export default Toast export default Toast;

View File

@ -1,38 +1,38 @@
/** @jsxImportSource @emotion/react */ import {t} from '@/scripts/i18n';
import React, { useState, useEffect, useRef } from 'react' import * as breakpoints from '@/styles/breakpoints';
import { useMeasure } from 'react-use' import * as cssUtils from '@/styles/utils';
import { css } from '@emotion/react' import {css} from '@emotion/react';
import styled from '@emotion/styled' import styled from '@emotion/styled';
import * as skinview3d from 'skinview3d' import {useEffect, useRef, useState} from 'react';
import { t } from '@/scripts/i18n' import {useMeasure} from 'react-use';
import * as cssUtils from '@/styles/utils' import * as skinview3d from 'skinview3d';
import * as breakpoints from '@/styles/breakpoints' import bg1 from '../../../misc/backgrounds/1.webp';
import SkinSteve from '../../../misc/textures/steve.png' import bg2 from '../../../misc/backgrounds/2.webp';
import bg1 from '../../../misc/backgrounds/1.webp' import bg3 from '../../../misc/backgrounds/3.webp';
import bg2 from '../../../misc/backgrounds/2.webp' import bg4 from '../../../misc/backgrounds/4.webp';
import bg3 from '../../../misc/backgrounds/3.webp' import bg5 from '../../../misc/backgrounds/5.webp';
import bg4 from '../../../misc/backgrounds/4.webp' import bg6 from '../../../misc/backgrounds/6.webp';
import bg5 from '../../../misc/backgrounds/5.webp' import bg7 from '../../../misc/backgrounds/7.webp';
import bg6 from '../../../misc/backgrounds/6.webp' import SkinSteve from '../../../misc/textures/steve.png';
import bg7 from '../../../misc/backgrounds/7.webp'
const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7] const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7];
export const PICTURES_COUNT = backgrounds.length export const PICTURES_COUNT = backgrounds.length;
interface Props { type Props = {
skin?: string readonly skin?: string;
cape?: string readonly cape?: string;
isAlex: boolean readonly children?: React.ReactNode;
showIndicator?: boolean readonly isAlex: boolean;
initPositionZ?: number readonly showIndicator?: boolean;
} readonly initPositionZ?: number;
};
const animationFactories = [ const animationFactories = [
() => new skinview3d.WalkingAnimation(), () => new skinview3d.WalkingAnimation(),
() => new skinview3d.RunningAnimation(), () => new skinview3d.RunningAnimation(),
() => new skinview3d.FlyingAnimation(), () => new skinview3d.FlyingAnimation(),
() => new skinview3d.IdleAnimation(), () => new skinview3d.IdleAnimation(),
] ];
const ActionButton = styled.i` const ActionButton = styled.i`
display: inline; display: inline;
@ -41,7 +41,7 @@ const ActionButton = styled.i`
color: #555; color: #555;
cursor: pointer; cursor: pointer;
} }
` `;
const cssViewer = css` const cssViewer = css`
flex: 1 1 auto; flex: 1 1 auto;
@ -56,251 +56,255 @@ const cssViewer = css`
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
` `;
const Viewer: React.FC<Props> = (props) => { const Viewer: React.FC<Props> = props => {
const { initPositionZ = 70 } = props const {initPositionZ = 70} = props;
const viewRef: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!) const viewReference: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!);
const containerRef = useRef<HTMLCanvasElement>(null) const containerReference = useRef<HTMLCanvasElement>(null);
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false);
const [animation, setAnimation] = useState(0) const [animation, setAnimation] = useState(0);
const [bgPicture, setBgPicture] = useState(-1) const [bgPicture, setBgPicture] = useState(-1);
const indicator = (() => { const indicator = (() => {
const { skin, cape } = props const {skin, cape} = props;
if (skin && cape) { if (skin && cape) {
return `${t('general.skin')} & ${t('general.cape')}` return `${t('general.skin')} & ${t('general.cape')}`;
} else if (skin) { }
return t('general.skin')
} else if (cape) {
return t('general.cape')
}
return ''
})()
useEffect(() => { if (skin) {
const container = containerRef.current! return t('general.skin');
const viewer = new skinview3d.SkinViewer({ }
canvas: container,
width: container.clientWidth,
height: container.clientHeight,
skin: props.skin || SkinSteve,
cape: props.cape || undefined,
model: props.isAlex ? 'slim' : 'default',
zoom: initPositionZ / 100,
})
viewer.autoRotate = true
if (document.body.classList.contains('dark-mode')) { if (cape) {
viewer.background = '#6c757d' return t('general.cape');
} }
viewRef.current = viewer return '';
})();
return () => { useEffect(() => {
viewer.dispose() const container = containerReference.current!;
} const viewer = new skinview3d.SkinViewer({
// eslint-disable-next-line react-hooks/exhaustive-deps canvas: container,
}, []) width: container.clientWidth,
height: container.clientHeight,
skin: props.skin || SkinSteve,
cape: props.cape || undefined,
model: props.isAlex ? 'slim' : 'default',
zoom: initPositionZ / 100,
});
viewer.autoRotate = true;
const [containerWrapperRef, containerMeasure] = useMeasure<HTMLDivElement>() if (document.body.classList.contains('dark-mode')) {
useEffect(() => { viewer.background = '#6c757d';
viewRef.current.setSize(containerMeasure.width, containerMeasure.height) }
})
useEffect(() => { viewReference.current = viewer;
const viewer = viewRef.current
viewer.loadSkin(props.skin || SkinSteve, {
model: props.isAlex ? 'slim' : 'default',
})
}, [props.skin, props.isAlex])
useEffect(() => { return () => {
const viewer = viewRef.current viewer.dispose();
if (props.cape) { };
viewer.loadCape(props.cape) // eslint-disable-next-line react-hooks/exhaustive-deps
} else { }, []);
viewer.resetCape()
}
}, [props.cape])
useEffect(() => { const [containerWrapperReference, containerMeasure] = useMeasure<HTMLDivElement>();
const viewer = viewRef.current useEffect(() => {
const factory = animationFactories[animation] viewReference.current.setSize(containerMeasure.width, containerMeasure.height);
if (factory === undefined) { });
viewer.animation = null
} else {
const newAnimation = factory()
newAnimation.paused = paused // Perseve `paused` state
viewer.animation = newAnimation
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animation])
useEffect(() => { useEffect(() => {
const currentAnimation = viewRef.current.animation const viewer = viewReference.current;
if (currentAnimation !== null) { viewer.loadSkin(props.skin || SkinSteve, {
currentAnimation.paused = paused model: props.isAlex ? 'slim' : 'default',
} });
}, [paused]) }, [props.skin, props.isAlex]);
useEffect(() => { useEffect(() => {
const viewer = viewRef.current const viewer = viewReference.current;
const backgroundUrl = backgrounds[bgPicture] if (props.cape) {
if (backgroundUrl === undefined) { viewer.loadCape(props.cape);
viewer.background = null } else {
} else { viewer.resetCape();
viewer.loadBackground(backgroundUrl) }
} }, [props.cape]);
}, [bgPicture])
const togglePause = () => { useEffect(() => {
setPaused((paused) => { const viewer = viewReference.current;
if (paused) { const factory = animationFactories[animation];
return false if (factory === undefined) {
} else { viewer.animation = null;
viewRef.current.autoRotate = false } else {
return true const newAnimation = factory();
} newAnimation.paused = paused; // Perseve `paused` state
}) viewer.animation = newAnimation;
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animation]);
const toggleAnimation = () => { useEffect(() => {
setAnimation((index) => (index + 1) % animationFactories.length) const currentAnimation = viewReference.current.animation;
setPaused(false) if (currentAnimation !== null) {
} currentAnimation.paused = paused;
}
}, [paused]);
const toggleRotate = () => { useEffect(() => {
const viewer = viewRef.current const viewer = viewReference.current;
viewer.autoRotate = !viewer.autoRotate const backgroundUrl = backgrounds[bgPicture];
} if (backgroundUrl === undefined) {
viewer.background = null;
} else {
viewer.loadBackground(backgroundUrl);
}
}, [bgPicture]);
const toggleBackEquippment = () => { const togglePause = () => {
const player = viewRef.current.playerObject setPaused(paused => {
if (player.backEquipment === 'cape') { if (paused) {
player.backEquipment = 'elytra' return false;
} else { }
player.backEquipment = 'cape'
}
}
const setWhite = () => { viewReference.current.autoRotate = false;
viewRef.current.background = '#fff' return true;
} });
const setGray = () => { };
viewRef.current.background = '#6c757d'
}
const setBlack = () => {
viewRef.current.background = '#000'
}
const setPrevPicture = () => {
setBgPicture((index) => {
if (bgPicture <= 0) {
return PICTURES_COUNT - 1
} else {
return index - 1
}
})
}
const setNextPicture = () => {
setBgPicture((index) => {
if (bgPicture >= PICTURES_COUNT - 1) {
return 0
} else {
return index + 1
}
})
}
return ( const toggleAnimation = () => {
<div className="card"> setAnimation(index => (index + 1) % animationFactories.length);
<div className="card-header"> setPaused(false);
<div className="d-flex justify-content-between"> };
<h3 className="card-title">
<span>{t('general.texturePreview')}</span>
{props.showIndicator && (
<span className="badge bg-olive ml-1">{indicator}</span>
)}
</h3>
<div>
<ActionButton
className={`fas fa-tablet ${props.cape ? '' : 'd-none'}`}
data-toggle="tooltip"
data-placement="bottom"
title={t('general.switchCapeElytra')}
onClick={toggleBackEquippment}
></ActionButton>
<ActionButton
className={`fas fa-person-running`}
data-toggle="tooltip"
data-placement="bottom"
title={t('general.switchAnimation')}
onClick={toggleAnimation}
></ActionButton>
<ActionButton
className={`fas fa-${paused ? 'play' : 'pause'}`}
data-toggle="tooltip"
data-placement="bottom"
title={
paused
? t('general.playAnimation')
: t('general.pauseAnimation')
}
onClick={togglePause}
></ActionButton>
<ActionButton
className="fas fa-rotate-right"
data-toggle="tooltip"
data-placement="bottom"
title={t('general.rotation')}
onClick={toggleRotate}
></ActionButton>
</div>
</div>
</div>
<div ref={containerWrapperRef} css={cssViewer} className="p-0">
<canvas ref={containerRef}></canvas>
</div>
<div className="card-footer">
<div className="mt-2 mb-3 d-flex">
<div
className="btn-color bg-white rounded-pill mr-2 elevation-2"
title={t('colors.white')}
onClick={setWhite}
/>
<div
className="btn-color bg-black rounded-pill mr-2 elevation-2"
title={t('colors.black')}
onClick={setBlack}
/>
<div
className="btn-color bg-gray rounded-pill mr-2 elevation-2"
title={t('colors.gray')}
onClick={setGray}
/>
<div
className="btn-color bg-green rounded-pill mr-2 elevation-2"
css={cssUtils.center}
title={t('colors.prev')}
onClick={setPrevPicture}
>
<i className="fas fa-arrow-left"></i>
</div>
<div
className="btn-color bg-green rounded-pill mr-2 elevation-2"
css={cssUtils.center}
title={t('colors.next')}
onClick={setNextPicture}
>
<i className="fas fa-arrow-right"></i>
</div>
</div>
{props.children}
</div>
</div>
)
}
export default Viewer const toggleRotate = () => {
const viewer = viewReference.current;
viewer.autoRotate = !viewer.autoRotate;
};
const toggleBackEquippment = () => {
const player = viewReference.current.playerObject;
player.backEquipment = player.backEquipment === 'cape' ? 'elytra' : 'cape';
};
const setWhite = () => {
viewReference.current.background = '#fff';
};
const setGray = () => {
viewReference.current.background = '#6c757d';
};
const setBlack = () => {
viewReference.current.background = '#000';
};
const setPreviousPicture = () => {
setBgPicture(index => {
if (bgPicture <= 0) {
return PICTURES_COUNT - 1;
}
return index - 1;
});
};
const setNextPicture = () => {
setBgPicture(index => {
if (bgPicture >= PICTURES_COUNT - 1) {
return 0;
}
return index + 1;
});
};
return (
<div className='card'>
<div className='card-header'>
<div className='d-flex justify-content-between'>
<h3 className='card-title'>
<span>{t('general.texturePreview')}</span>
{props.showIndicator
&& <span className='badge bg-olive ml-1'>{indicator}</span>}
</h3>
<div>
<ActionButton
className={`fas fa-tablet ${props.cape ? '' : 'd-none'}`}
data-toggle='tooltip'
data-placement='bottom'
title={t('general.switchCapeElytra')}
onClick={toggleBackEquippment}
/>
<ActionButton
className='fas fa-person-running'
data-toggle='tooltip'
data-placement='bottom'
title={t('general.switchAnimation')}
onClick={toggleAnimation}
/>
<ActionButton
className={`fas fa-${paused ? 'play' : 'pause'}`}
data-toggle='tooltip'
data-placement='bottom'
title={
paused
? t('general.playAnimation')
: t('general.pauseAnimation')
}
onClick={togglePause}
/>
<ActionButton
className='fas fa-rotate-right'
data-toggle='tooltip'
data-placement='bottom'
title={t('general.rotation')}
onClick={toggleRotate}
/>
</div>
</div>
</div>
<div ref={containerWrapperReference} css={cssViewer} className='p-0'>
<canvas ref={containerReference}/>
</div>
<div className='card-footer'>
<div className='mt-2 mb-3 d-flex'>
<div
className='btn-color bg-white rounded-pill mr-2 elevation-2'
title={t('colors.white')}
onClick={setWhite}
/>
<div
className='btn-color bg-black rounded-pill mr-2 elevation-2'
title={t('colors.black')}
onClick={setBlack}
/>
<div
className='btn-color bg-gray rounded-pill mr-2 elevation-2'
title={t('colors.gray')}
onClick={setGray}
/>
<div
className='btn-color bg-green rounded-pill mr-2 elevation-2'
css={cssUtils.center}
title={t('colors.prev')}
onClick={setPreviousPicture}
>
<i className='fas fa-arrow-left'/>
</div>
<div
className='btn-color bg-green rounded-pill mr-2 elevation-2'
css={cssUtils.center}
title={t('colors.next')}
onClick={setNextPicture}
>
<i className='fas fa-arrow-right'/>
</div>
</div>
{props.children}
</div>
</div>
);
};
export default Viewer;

View File

@ -1,17 +1,17 @@
import React from 'react' import {t} from '@/scripts/i18n';
import { t } from '@/scripts/i18n'
const ViewerSkeleton: React.FC = () => ( export default function ViewerSkeleton() {
<div className="card"> return (
<div className="card-header"> <div className='card'>
<div className="d-flex justify-content-between"> <div className='card-header'>
<h3 className="card-title"> <div className='d-flex justify-content-between'>
<span>{t('general.texturePreview')}</span> <h3 className='card-title'>
</h3> <span>{t('general.texturePreview')}</span>
</div> </h3>
</div> </div>
<div className="card-body"></div> </div>
</div> <div className='card-body'/>
) </div>
);
}
export default ViewerSkeleton

View File

@ -1,5 +1,5 @@
@import '../fonts/minecraft.css'; @import '@/fonts/minecraft.css';
@import './avatar.css'; @import '@/styles/avatar.css';
body { body {
font-size: 16px; font-size: 16px;

View File

@ -1,37 +1,35 @@
import * as React from 'react' import $ from 'jquery';
import * as ReactDOM from 'react-dom' import React from 'react';
import $ from 'jquery' import ReactDOM from 'react-dom';
import './scripts/app' import {createRoot} from 'react-dom/client';
import routes from './scripts/route' import routes from './scripts/route';
Object.assign(window, { React, ReactDOM, $ }) import './scripts/app';
const entry = document.querySelector('[href="#launch-cli"]') // eslint-disable-next-line ts/naming-convention
entry?.addEventListener('click', async () => { Object.assign(window, {React, ReactDOM, $});
const { launch } = await import('./scripts/cli')
launch()
})
const route = routes.find((route) => const route = routes.find(route =>
new RegExp(`^${route.path}$`, 'i').test(blessing.route), new RegExp(`^${route.path}$`, 'i').test(blessing.route));
)
if (route) { if (route) {
if (route.module) { if (route.module) {
Promise.all(route.module.map((m) => m())) void Promise.all(route.module.map(async m => m()));
} }
if (route.react) {
const Component = React.lazy( if (route.react) {
route.react as () => Promise<{ default: React.ComponentType }>, const Component = React.lazy(route.react as () => Promise<{default: React.ComponentType}>);
)
const Root = () => ( const container = typeof route.el === 'string'
<React.StrictMode> ? document.querySelector(route.el)
<React.Suspense fallback={route.frame?.() ?? ''}> : null;
<Component />
</React.Suspense> const root = createRoot(container!);
</React.StrictMode> root.render((
) <React.StrictMode>
const c = <React.Suspense fallback={route.frame?.() ?? ''}>
typeof route.el === 'string' ? document.querySelector(route.el) : route.el <Component/>
ReactDOM.render(<Root />, c) </React.Suspense>
} </React.StrictMode>
));
}
} }

View File

@ -1,14 +1,15 @@
import './init' // must be first import {Tooltip} from 'bootstrap';
import 'admin-lte' import '@popperjs/core';
import './extra' import 'admin-lte';
import './i18n' import './extra';
import './net' import './i18n';
import './event' import './net';
import './notification' import './event';
import './emailVerification' import './notification';
import './logout' import './emailVerification';
import './darkMode' import './logout';
import './darkMode';
window.addEventListener('load', () => { window.addEventListener('load', () => {
$('[data-toggle="tooltip"]').tooltip() [...document.querySelectorAll('[data-toggle="tooltip"]')].map(el => new Tooltip(el));
}) });

View File

@ -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}>
&times;
</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)
}

View File

@ -1,2 +0,0 @@
rules:
'@typescript-eslint/no-empty-function': off

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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')
}
}

View File

@ -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')
}
}

View File

@ -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
}

View File

@ -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}`)
}

View File

@ -1,9 +0,0 @@
export function emitKeypressEvents() {}
export function createInterface() {
return {
pause() {},
resume() {},
close() {},
}
}

View File

@ -1,9 +1,8 @@
import * as React from 'react' import DarkModeButton from '@/components/DarkModeButton';
import * as ReactDOM from 'react-dom' import ReactDOM from 'react-dom';
import DarkModeButton from '@/components/DarkModeButton'
const el = document.querySelector('#toggle-dark-mode') const element = document.querySelector('#toggle-dark-mode');
if (el) { if (element) {
const initMode = document.body.classList.contains('dark-mode') const initMode = document.body.classList.contains('dark-mode');
ReactDOM.render(<DarkModeButton initMode={initMode} />, el) ReactDOM.render(<DarkModeButton initMode={initMode}/>, element);
} }

View File

@ -1,9 +1,8 @@
import React from 'react' import EmailVerification from '@/views/widgets/EmailVerification';
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom';
import EmailVerification from '@/views/widgets/EmailVerification'
const container = document.querySelector('#email-verification') const container = document.querySelector('#email-verification');
if (blessing.extra.unverified && container) { if (blessing.extra.unverified && container) {
ReactDOM.render(<EmailVerification />, container) ReactDOM.render(<EmailVerification/>, container);
} }

View File

@ -1,20 +1,22 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */ const bus = new Map<string | symbol, Set<(...args: any[]) => void>>();
const bus = new Map<string | symbol, Set<CallableFunction>>()
export function on(event: string | symbol, listener: CallableFunction) { export function on(event: string | symbol, listener: (...args: any[]) => void) {
if (!bus.has(event)) { if (!bus.has(event)) {
bus.set(event, new Set()) bus.set(event, new Set());
} }
const listeners = bus.get(event)!
listeners.add(listener)
return () => { const listeners = bus.get(event)!;
listeners.delete(listener) listeners.add(listener);
}
return () => {
listeners.delete(listener);
};
} }
export function emit(event: string | symbol, payload?: unknown) { export function emit(event: string | symbol, payload?: unknown) {
bus.get(event)?.forEach((listener) => listener(payload)) bus.get(event)?.forEach(listener => {
listener(payload);
});
} }
blessing.event = { on, emit } blessing.event = {on, emit};

View File

@ -1,11 +1,11 @@
export function getExtraData(): Record<string, any> { export function getExtraData(): Record<string, any> {
const jsonElement = document.querySelector('#blessing-extra') const jsonElement = document.querySelector('#blessing-extra');
/* istanbul ignore next */
if (jsonElement) { if (jsonElement) {
return JSON.parse(jsonElement.textContent ?? '{}') return JSON.parse(jsonElement.textContent ?? '{}');
} else { }
return {}
} return {};
} }
blessing.extra = getExtraData() blessing.extra = getExtraData();

View File

@ -1,25 +1,24 @@
import { getExtraData } from './extra' import {getExtraData} from './extra';
export function scrollHander() { export function scrollHander() {
const header = document.querySelector('.navbar') const header = document.querySelector('.navbar');
/* istanbul ignore else */ /* istanbul ignore else */
if (header) { if (header) {
window.addEventListener('scroll', () => { window.addEventListener('scroll', () => {
if (window.scrollY >= (window.innerHeight * 2) / 3) { if (window.scrollY >= (window.innerHeight * 2) / 3) {
header.classList.remove('transparent') header.classList.remove('transparent');
} else { } else {
header.classList.add('transparent') header.classList.add('transparent');
} }
}) });
} }
} }
/* istanbul ignore next */
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
const { transparent_navbar } = getExtraData() as { const {transparent_navbar} = getExtraData() as {
transparent_navbar: boolean transparent_navbar: boolean;
} };
if (transparent_navbar) { if (transparent_navbar) {
window.addEventListener('load', scrollHander) window.addEventListener('load', scrollHander);
} }
} }

View File

@ -1,11 +1,11 @@
import { useState, useEffect } from 'react' import {useEffect, useState} from 'react';
export default function useBlessingExtra<T>(key: string, defaultValue?: T): T { export default function useBlessingExtra<T>(key: string, defaultValue?: T): T {
const [value, setValue] = useState<T>(defaultValue!) const [value, setValue] = useState<T>(defaultValue!);
useEffect(() => { useEffect(() => {
setValue(blessing.extra[key] as T) setValue(blessing.extra[key] as T);
}, [key]) }, [key]);
return value return value;
} }

View File

@ -1,8 +1,8 @@
import { useEffect } from 'react' import {useEffect} from 'react';
import { emit } from '../event' import {emit} from '../event';
export default function useEmitMounted() { export default function useEmitMounted() {
useEffect(() => { useEffect(() => {
emit('mounted') emit('mounted');
}, []) }, []);
} }

View File

@ -1,13 +1,13 @@
import { useState, useEffect } from 'react' import {useEffect, useState} from 'react';
export default function useIsLargeScreen() { export default function useIsLargeScreen() {
const [isLarge, setIsLarge] = useState(false) const [isLarge, setIsLarge] = useState(false);
useEffect(() => { useEffect(() => {
if (window.innerWidth >= 992) { if (window.innerWidth >= 992) {
setIsLarge(true) setIsLarge(true);
} }
}, []) }, []);
return isLarge return isLarge;
} }

View File

@ -1,20 +1,20 @@
import { useEffect, useRef } from 'react' import {useEffect, useRef} from 'react';
export default function useMount(selector: string): HTMLElement | null { export default function useMount(selector: string): HTMLElement | undefined {
const container = useRef<HTMLDivElement | null>(null) const container = useRef<HTMLDivElement | undefined>(null);
useEffect(() => { useEffect(() => {
const mount = document.querySelector(selector)! const mount = document.querySelector(selector)!;
const div = document.createElement('div') const div = document.createElement('div');
container.current = div container.current = div;
mount.appendChild(div) mount.append(div);
return () => { return () => {
mount.removeChild(div) div.remove();
container.current = null container.current = null;
} };
}, [selector]) }, [selector]);
return container.current return container.current;
} }

View File

@ -1,26 +1,27 @@
import { useState, useEffect } from 'react' import {useEffect, useState} from 'react';
import * as fetch from '../net' import * as fetch from '../net';
import { Texture, TextureType } from '../types' import {type Texture, TextureType} from '../types';
export default function useTexture() { export default function useTexture() {
const [tid, setTid] = useState(0) const [tid, setTid] = useState(0);
const [url, setUrl] = useState('') const [url, setUrl] = useState('');
const [type, setType] = useState(TextureType.Steve) const [type, setType] = useState(TextureType.Steve);
useEffect(() => { useEffect(() => {
if (tid <= 0) { if (tid <= 0) {
setUrl('') setUrl('');
return return;
} }
const getTexture = async () => { const getTexture = async () => {
const { hash, type } = await fetch.get<Texture>(`/skinlib/info/${tid}`) const {hash, type} = await fetch.get<Texture>(`/skinlib/info/${tid}`);
setUrl(`${blessing.base_url}/textures/${hash}`) setUrl(`${blessing.base_url}/textures/${hash}`);
setType(type) setType(type);
} };
getTexture()
}, [tid])
return [{ url, type }, setTid] as const getTexture();
}, [tid]);
return [{url, type}, setTid] as const;
} }

View File

@ -1,22 +1,22 @@
import { useState, useEffect, useRef } from 'react' import TWEEN from '@tweenjs/tween.js';
import TWEEN from '@tweenjs/tween.js' import {useEffect, useRef, useState} from 'react';
export default function useTween<T = any>(initialValue: T) { export default function useTween<T = any>(initialValue: T) {
const [value, setValue] = useState<T>(initialValue) const [value, setValue] = useState<T>(initialValue);
const ref = useRef<T>(value) const reference = useRef<T>(value);
const [dest, setDest] = useState<T>(initialValue) const [destination, setDestination] = useState<T>(initialValue);
useEffect(() => { useEffect(() => {
function animate() { function animate() {
requestAnimationFrame(animate) requestAnimationFrame(animate);
TWEEN.update() TWEEN.update();
setValue(ref.current) setValue(reference.current);
} }
const tween = new TWEEN.Tween(ref) const tween = new TWEEN.Tween(reference);
tween.to({ current: dest }, 1000).start() tween.to({current: destination}, 1000).start();
animate() animate();
}, [dest]) }, [destination]);
return [value, setDest] as const return [value, setDestination] as const;
} }

View File

@ -1,31 +1,34 @@
interface I18nTable { type I18nTable = {
[key: string]: string | I18nTable | undefined [key: string]: string | I18nTable | undefined;
};
export function t(
key: string,
parameters: Record<string, string> = Object.create(null) as Record<string, string>,
): string {
const segments = key.split('.');
let temporary = blessing.i18n as I18nTable | undefined;
let result = '';
for (const segment of segments) {
const middle = temporary?.[segment];
if (middle === undefined) {
return key;
}
if (typeof middle === 'string') {
result = middle;
} else {
temporary = middle;
}
}
for (const slot of Object.keys(parameters)) {
result = result.replace(`:${slot}`, parameters[slot] ?? `%{${slot}}`);
}
return result;
} }
export function t(key: string, parameters = Object.create(null)): string { Object.assign(window, {trans: t});
const segments = key.split('.') Object.assign(blessing, {t});
let temp = blessing.i18n as I18nTable | undefined
let result = ''
for (const segment of segments) {
/* istanbul ignore next */
const middle = temp?.[segment]
if (!middle) {
return key
}
if (typeof middle === 'string') {
result = middle
} else {
temp = middle
}
}
Object.keys(parameters).forEach(
(slot) => (result = result.replace(`:${slot}`, parameters[slot])),
)
return result
}
Object.assign(window, { trans: t })
Object.assign(blessing, { t })

View File

@ -1,12 +1,12 @@
declare let __webpack_public_path__: string declare let __webpack_public_path__: string;
declare const __blessing_public_path__: string declare const __blessing_public_path__: string;
if (process.env.NODE_ENV === 'development') { if (import.meta.env.NODE_ENV === 'development') {
__webpack_public_path__ = __blessing_public_path__ __webpack_public_path__ = __blessing_public_path__;
} else { } else {
const link = document.querySelector<HTMLLinkElement>('link#cdn-host') const link = document.querySelector<HTMLLinkElement>('link#cdn-host');
const base = link?.href ?? blessing.base_url const base = link?.href ?? blessing.base_url;
__webpack_public_path__ = `${base}/app/` __webpack_public_path__ = `${base}/app/`;
} }
export {} export {};

View File

@ -1,22 +1,22 @@
import { post } from './net' import {t} from './i18n';
import { t } from './i18n' import {post} from './net';
import { showModal } from './notify' import {showModal} from './notify';
import urls from './urls' import urls from './urls';
export async function logout() { export async function logout() {
try { try {
await showModal({ await showModal({
text: t('general.confirmLogout'), text: t('general.confirmLogout'),
center: true, center: true,
}) });
} catch { } catch {
return return;
} }
await post(urls.auth.logout()) await post(urls.auth.logout());
window.location.href = blessing.base_url window.location.href = blessing.base_url;
} }
const button = document.querySelector('#logout-button') const button = document.querySelector('#logout-button');
/* istanbul ignore next */
button?.addEventListener('click', logout) button?.addEventListener('click', logout);

View File

@ -1,27 +1,26 @@
import React from 'react' import {createRoot} from 'react-dom/client';
import ReactDOM from 'react-dom' import Modal, {type ModalOptions, type ModalResult} from '../components/Modal';
import Modal, { ModalOptions, ModalResult } from '../components/Modal'
export function showModal(options: ModalOptions = {}): Promise<ModalResult> { export async function showModal(options: ModalOptions = {}): Promise<ModalResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const container = document.createElement('div') const container = document.createElement('div');
document.body.appendChild(container) document.body.append(container);
const root = createRoot(container);
const handleClose = () => { const handleClose = () => {
ReactDOM.unmountComponentAtNode(container) root.unmount();
document.body.removeChild(container) container.remove();
} };
ReactDOM.render( root.render((
<Modal <Modal
{...options} {...options}
show show
center center
onConfirm={resolve} onConfirm={resolve}
onDismiss={reject} onDismiss={reject}
onClose={handleClose} onClose={handleClose}
/>, />
container, ));
) });
})
} }

View File

@ -1,159 +1,162 @@
import { emit } from './event' import {emit} from './event';
import { showModal } from './notify' import {t} from './i18n';
import { t } from './i18n' import {showModal} from './notify';
export interface ResponseBody<T = null> { export type ResponseBody<T = undefined> = {
code: number code: number;
message: string message: string;
data: T extends null ? never : T data: T extends undefined ? never : T;
} };
class HTTPError extends Error { class HTTPError extends Error {
response: Response response: Response;
constructor(message: string, response: Response) { constructor(message: string, response: Response) {
super(message) super(message);
this.response = response this.response = response;
} }
} }
const empty = Object.create(null) const empty: Record<string, never> = Object.create(null);
export const init: RequestInit = { export const init: RequestInit = {
credentials: 'same-origin', credentials: 'same-origin',
headers: new Headers({ headers: new Headers({
Accept: 'application/json', Accept: 'application/json',
}), }),
} };
function retrieveToken() { function retrieveToken() {
const csrfField = document.querySelector<HTMLMetaElement>( const csrfField = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]');
'meta[name="csrf-token"]',
) return csrfField?.content || '';
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return csrfField?.content || ''
} }
export async function walkFetch(request: Request): Promise<any> { export async function walkFetch(request: Request): Promise<any> {
request.headers.set('X-CSRF-TOKEN', retrieveToken()) request.headers.set('X-CSRF-TOKEN', retrieveToken());
try { try {
const response = await fetch(request) const response = await fetch(request);
const cloned = response.clone() const cloned = response.clone();
const body = const body
response.headers.get('Content-Type') === 'application/json' = response.headers.get('Content-Type') === 'application/json'
? await response.json() ? await response.json()
: await response.text() : await response.text();
if (response.ok) { if (response.ok) {
return body return body;
} }
let message: string = body.message
if (response.status === 422) { let {message} = body;
// Process validation errors from Laravel.
const {
errors,
}: {
message: string
errors: { [field: string]: string[] }
} = body
return {
code: 1,
message: Object.keys(errors).map((field) => errors[field]![0])[0],
}
} else if (response.status === 419) {
return showModal({
mode: 'alert',
text: t('general.csrf'),
})
} else if (response.status === 403 || response.status === 400) {
return showModal({
mode: 'alert',
text: message,
type: 'warning',
})
}
if (body.exception && Array.isArray(body.trace)) { if (response.status === 422) {
const trace = (body.trace as Array<{ file: string; line: number }>) // Process validation errors from Laravel.
.map((t, i) => `[${i + 1}] ${t.file}#L${t.line}`) const {
.join('<br>') errors,
message = `${message}<br><details>${trace}</details>` }: {
} message: string;
errors: Record<string, string[]>;
} = body;
return {
code: 1,
message: Object.keys(errors).map(field => errors[field][0])[0],
};
}
throw new HTTPError(message || body, cloned) if (response.status === 419) {
} catch (error: any) { return await showModal({
emit('fetchError', error) mode: 'alert',
await showModal({ text: t('general.csrf'),
mode: 'alert', });
title: t('general.fatalError'), }
dangerousHTML: error.message,
type: 'danger',
okButtonType: 'outline-light',
})
return { code: -1, message: t('general.fatalError') } if (response.status === 403 || response.status === 400) {
} return await showModal({
mode: 'alert',
text: message,
type: 'warning',
});
}
if (body.exception && Array.isArray(body.trace)) {
const trace = (body.trace as Array<{file: string; line: number}>)
.map((t, i) => `[${i + 1}] ${t.file}#L${t.line}`)
.join('<br>');
message = `${message}<br><details>${trace}</details>`;
}
throw new HTTPError(message || String(body), cloned);
} catch (error: any) {
emit('fetchError', error);
await showModal({
mode: 'alert',
title: t('general.fatalError'),
dangerousHTML: error.message,
type: 'danger',
okButtonType: 'outline-light',
});
return {code: -1, message: t('general.fatalError')};
}
} }
export function get<T = any>(url: string, params = empty): Promise<T> { export async function get<T = any>(url: string, parameters: Record<string, string> | URLSearchParams = empty): Promise<T> {
emit('beforeFetch', { emit('beforeFetch', {
method: 'GET', method: 'GET',
url, url,
data: params, data: parameters,
}) });
const qs = new URLSearchParams(params).toString() const qs = new URLSearchParams(parameters).toString();
return walkFetch(new Request(`${blessing.base_url}${url}?${qs}`, init)) return walkFetch(new Request(`${blessing.base_url}${url}?${qs}`, init));
} }
function nonGet<T = any>( async function nonGet<T = any>(
method: string, method: string,
url: string, url: string,
data?: FormData | Record<string, unknown>, data?: FormData | Record<string, unknown>,
): Promise<T> { ): Promise<T> {
emit('beforeFetch', { emit('beforeFetch', {
method: method.toUpperCase(), method: method.toUpperCase(),
url, url,
data, data,
}) });
const request = new Request(`${blessing.base_url}${url}`, { const request = new Request(`${blessing.base_url}${url}`, {
body: data instanceof FormData ? data : JSON.stringify(data), body: data instanceof FormData ? data : JSON.stringify(data),
method: method.toUpperCase(), method: method.toUpperCase(),
...init, ...init,
}) });
if (!(data instanceof FormData)) { if (!(data instanceof FormData)) {
request.headers.set('Content-Type', 'application/json') request.headers.set('Content-Type', 'application/json');
} }
return walkFetch(request) return walkFetch(request);
} }
export function post<T = any>( export async function post<T = any>(
url: string, url: string,
data?: FormData | Record<string, unknown>, data?: FormData | Record<string, unknown>,
): Promise<T> { ): Promise<T> {
return nonGet<T>('POST', url, data) return nonGet<T>('POST', url, data);
} }
export function put<T = any>( export async function put<T = any>(
url: string, url: string,
data?: FormData | Record<string, unknown>, data?: FormData | Record<string, unknown>,
): Promise<T> { ): Promise<T> {
return nonGet<T>('PUT', url, data) return nonGet<T>('PUT', url, data);
} }
export function del<T = any>( export async function del<T = any>(
url: string, url: string,
data?: FormData | Record<string, unknown>, data?: FormData | Record<string, unknown>,
): Promise<T> { ): Promise<T> {
return nonGet<T>('DELETE', url, data) return nonGet<T>('DELETE', url, data);
} }
blessing.fetch = { blessing.fetch = {
get, get,
post, post,
put, put,
del, del,
} };

View File

@ -1,8 +1,7 @@
import React from 'react' import NotificationsList from '@/views/widgets/NotificationsList';
import ReactDOM from 'react-dom' import {createRoot} from 'react-dom/client';
import NotificationsList from '@/views/widgets/NotificationsList'
const container = document.querySelector('[data-notifications]') const container = document.querySelector('[data-notifications]');
if (container) { if (container) {
ReactDOM.render(<NotificationsList />, container) createRoot(container).render(<NotificationsList/>);
} }

View File

@ -1,15 +1,14 @@
import { showModal } from './modal' import {showModal} from './modal';
import { Toast } from './toast' import {Toast} from './toast';
export const toast = new Toast() export const toast = new Toast();
/* istanbul ignore next */ if (import.meta.env.NODE_ENV === 'test') {
if (process.env.NODE_ENV === 'test') { afterEach(() => {
afterEach(() => { toast.clear();
toast.clear() });
})
} }
Object.assign(blessing, { notify: { showModal, toast } }) Object.assign(blessing, {notify: {showModal, toast}});
export { showModal } from './modal' export {showModal} from './modal';

View File

@ -1,118 +1,116 @@
import React from 'react'
export default [ export default [
{ {
path: 'user', path: 'user',
react: () => import('../views/user/Dashboard'), react: async () => import('../views/user/Dashboard'),
el: '#usage-box', el: '#usage-box',
frame: () => ( frame: () => (
<div className="card card-primary card-outline"> <div className='card card-primary card-outline'>
<div className="card-header">&nbsp;</div> <div className='card-header'>&nbsp;</div>
<div className="card-body"></div> <div className='card-body'/>
<div className="card-footer">&nbsp;</div> <div className='card-footer'>&nbsp;</div>
</div> </div>
), ),
}, },
{ {
path: 'user/closet', path: 'user/closet',
react: () => import('../views/user/Closet'), react: async () => import('../views/user/Closet'),
el: '#closet-list', el: '#closet-list',
}, },
{ {
path: 'user/player', path: 'user/player',
react: () => import('../views/user/Players'), react: async () => import('../views/user/Players'),
el: '#players-list', el: '#players-list',
frame: () => ( frame: () => (
<div className="card"> <div className='card'>
<div className="card-header">&nbsp;</div> <div className='card-header'>&nbsp;</div>
<div className="card-body p-0"></div> <div className='card-body p-0'/>
</div> </div>
), ),
}, },
{ {
path: 'user/profile', path: 'user/profile',
module: [() => import('../views/user/profile/index')], module: [async () => import('../views/user/profile/index')],
}, },
{ {
path: 'user/oauth/manage', path: 'user/oauth/manage',
react: () => import('../views/user/OAuth'), react: async () => import('../views/user/OAuth'),
el: '.content > .container-fluid', el: '.content > .container-fluid',
}, },
{ {
path: 'admin', path: 'admin',
module: [() => import('../views/admin/Dashboard')], module: [async () => import('../views/admin/Dashboard')],
}, },
{ {
path: 'admin/users', path: 'admin/users',
react: () => import('../views/admin/UsersManagement'), react: async () => import('../views/admin/UsersManagement'),
el: '.content > .container-fluid', el: '.content > .container-fluid',
}, },
{ {
path: 'admin/players', path: 'admin/players',
react: () => import('../views/admin/PlayersManagement'), react: async () => import('../views/admin/PlayersManagement'),
el: '.content > .container-fluid', el: '.content > .container-fluid',
}, },
{ {
path: 'admin/reports', path: 'admin/reports',
react: () => import('../views/admin/ReportsManagement'), react: async () => import('../views/admin/ReportsManagement'),
el: '.content > .container-fluid', el: '.content > .container-fluid',
}, },
{ {
path: 'admin/customize', path: 'admin/customize',
module: [() => import('../views/admin/Customization')], module: [async () => import('../views/admin/Customization')],
}, },
{ {
path: 'admin/i18n', path: 'admin/i18n',
react: () => import('../views/admin/Translations'), react: async () => import('../views/admin/Translations'),
el: '#table', el: '#table',
}, },
{ {
path: 'admin/plugins/manage', path: 'admin/plugins/manage',
react: () => import('../views/admin/PluginsManagement'), react: async () => import('../views/admin/PluginsManagement'),
el: '.content > .container-fluid', el: '.content > .container-fluid',
}, },
{ {
path: 'admin/plugins/market', path: 'admin/plugins/market',
react: () => import('../views/admin/PluginsMarket'), react: async () => import('../views/admin/PluginsMarket'),
el: '.content > .container-fluid', el: '.content > .container-fluid',
}, },
{ {
path: 'admin/update', path: 'admin/update',
module: [() => import('../views/admin/Update')], module: [async () => import('../views/admin/Update')],
}, },
{ {
path: 'auth/login', path: 'auth/login',
react: () => import('../views/auth/Login'), react: async () => import('../views/auth/Login'),
el: 'main', el: 'main',
}, },
{ {
path: 'auth/register', path: 'auth/register',
react: () => import('../views/auth/Registration'), react: async () => import('../views/auth/Registration'),
el: 'main', el: 'main',
}, },
{ {
path: 'auth/forgot', path: 'auth/forgot',
react: () => import('../views/auth/Forgot'), react: async () => import('../views/auth/Forgot'),
el: 'main', el: 'main',
}, },
{ {
path: 'auth/reset/(\\d+)', path: 'auth/reset/(\\d+)',
react: () => import('../views/auth/Reset'), react: async () => import('../views/auth/Reset'),
el: 'main', el: 'main',
}, },
{ {
path: 'skinlib', path: 'skinlib',
react: () => import('../views/skinlib/SkinLibrary'), react: async () => import('../views/skinlib/SkinLibrary'),
el: '.content-wrapper', el: '.content-wrapper',
}, },
{ {
path: 'skinlib/show/(\\d+)', path: 'skinlib/show/(\\d+)',
react: () => import('../views/skinlib/Show'), react: async () => import('../views/skinlib/Show'),
el: '#side', el: '#side',
}, },
{ {
path: 'skinlib/upload', path: 'skinlib/upload',
react: () => import('../views/skinlib/Upload'), react: async () => import('../views/skinlib/Upload'),
el: '#file-input', el: '#file-input',
}, },
] ];

View File

@ -1,51 +1,49 @@
import { loadSkinToCanvas } from 'skinview-utils' import {loadSkinToCanvas} from 'skinview-utils';
/* istanbul ignore next */
function checkPixel( function checkPixel(
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
x: number, x: number,
y: number, y: number,
): boolean { ): boolean {
const imageData = context.getImageData(x, y, 1, 1) const imageData = context.getImageData(x, y, 1, 1);
return ( return (
imageData.data[0] === 0 && imageData.data[0] === 0
imageData.data[1] === 0 && && imageData.data[1] === 0
imageData.data[2] === 0 && imageData.data[2] === 0
) );
} }
/* istanbul ignore next */ export async function isAlex(texture: string): Promise<boolean> {
export function isAlex(texture: string): Promise<boolean> { return new Promise(resolve => {
return new Promise((resolve) => { const image = new Image();
const image = new Image() image.src = texture;
image.src = texture image.addEventListener('load', () => {
image.onload = () => { if (image.width !== image.height) {
if (image.width !== image.height) { resolve(false);
resolve(false) return;
return }
}
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas');
loadSkinToCanvas(canvas, image) loadSkinToCanvas(canvas, image);
const ratio = canvas.width / 64 const ratio = canvas.width / 64;
const context = canvas.getContext('2d') const context = canvas.getContext('2d');
if (!context) { if (!context) {
resolve(false) resolve(false);
return return;
} }
for (let x = 46 * ratio; x < 48 * ratio; x += 1) { for (let x = 46 * ratio; x < 48 * ratio; x += 1) {
for (let y = 52 * ratio; y < 64 * ratio; y += 1) { for (let y = 52 * ratio; y < 64 * ratio; y += 1) {
if (!checkPixel(context, x, y)) { if (!checkPixel(context, x, y)) {
resolve(false) resolve(false);
return return;
} }
} }
} }
resolve(true) resolve(true);
} });
}) });
} }

View File

@ -1,93 +1,101 @@
import React, { useState, useEffect } from 'react' import {nanoid} from 'nanoid';
import ReactDOM from 'react-dom' import React, {useEffect, useState} from 'react';
import { nanoid } from 'nanoid' import {createRoot, type Root} from 'react-dom/client';
import * as emitter from './event' import ToastBox, {type ToastType} from '../components/Toast';
import ToastBox, { ToastType } from '../components/Toast' import * as emitter from './event';
type QueueElement = { id: string; type: ToastType; message: string } type QueueElement = {id: string; type: ToastType; message: string};
type ToastQueue = QueueElement[] type ToastQueue = QueueElement[];
const TOAST_EVENT = Symbol('toast') const ToastEvent = Symbol('toast');
const CLEAR_EVENT = Symbol('clear') const ClearEvent = Symbol('clear');
export const ToastContainer: React.FC = () => { export function ToastContainer() {
const [queue, setQueue] = useState<ToastQueue>([]) const [queue, setQueue] = useState<ToastQueue>([]);
const handleClose = (id: string) => { const handleClose = (id: string) => {
setQueue((queue) => queue.filter((el) => el.id !== id)) setQueue(queue => queue.filter(element => element.id !== id));
} };
useEffect(() => { useEffect(() => {
const off1 = emitter.on(TOAST_EVENT, (toast: QueueElement) => { const off1 = emitter.on(ToastEvent, (toast: QueueElement) => {
setQueue((queue) => { setQueue(queue => {
queue.push(toast) queue.push(toast);
return queue.slice() return [...queue];
}) });
setTimeout(() => { // Effect dependency is empty
handleClose(toast.id) // eslint-disable-next-line react-web-api/no-leaked-timeout
}, 3100) setTimeout(() => {
}) handleClose(toast.id);
const off2 = emitter.on(CLEAR_EVENT, () => setQueue([])) }, 3100);
});
const off2 = emitter.on(ClearEvent, () => {
setQueue([]);
});
return () => { return () => {
off1() off1();
off2() off2();
} };
}, []) }, []);
return ( return (
<> <>
{queue.map((el, i) => ( {queue.map((element, i) => (
<ToastBox <ToastBox
key={el.id} key={element.id}
type={el.type} type={element.type}
distance={50 + i * 70} distance={50 + (i * 70)}
onClose={() => handleClose(el.id)} onClose={() => {
> handleClose(element.id);
{el.message} }}
</ToastBox> >
))} {element.message}
</> </ToastBox>
) ))}
</>
);
} }
export class Toast { export class Toast {
private container: HTMLDivElement private readonly container: HTMLDivElement;
private readonly root: Root;
constructor(render?: (element: JSX.Element) => void) { constructor(render?: (element: React.JSX.Element) => void) {
this.container = document.createElement('div') this.container = document.createElement('div');
document.body.appendChild(this.container) document.body.append(this.container);
this.root = createRoot(this.container);
if (render) { if (render) {
render(<ToastContainer />) render(<ToastContainer/>);
} else { } else {
ReactDOM.render(<ToastContainer />, this.container) this.root.render(<ToastContainer/>);
} }
} }
success(message: string) { success(message: string) {
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'success', message }) emitter.emit(ToastEvent, {id: nanoid(4), type: 'success', message});
} }
info(message: string) { info(message: string) {
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'info', message }) emitter.emit(ToastEvent, {id: nanoid(4), type: 'info', message});
} }
warning(message: string) { warning(message: string) {
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'warning', message }) emitter.emit(ToastEvent, {id: nanoid(4), type: 'warning', message});
} }
error(message: string) { error(message: string) {
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'error', message }) emitter.emit(ToastEvent, {id: nanoid(4), type: 'error', message});
} }
clear() { clear() {
emitter.emit(CLEAR_EVENT) emitter.emit(ClearEvent);
} }
dispose() { dispose() {
ReactDOM.unmountComponentAtNode(this.container) this.root.unmount();
this.container.remove() this.container.remove();
} }
} }

View File

@ -1,61 +1,61 @@
export type User = { export type User = {
uid: number uid: number;
email: string email: string;
nickname: string nickname: string;
locale: string | null locale: string | undefined;
score: number score: number;
avatar: number avatar: number;
permission: UserPermission permission: UserPermission;
ip: string ip: string;
is_dark_mode: boolean is_dark_mode: boolean;
last_sign_at: string last_sign_at: string;
register_at: string register_at: string;
verified: boolean verified: boolean;
} };
export const enum UserPermission { export const enum UserPermission {
Banned = -1, Banned = -1,
Normal = 0, Normal = 0,
Admin = 1, Admin = 1,
SuperAdmin = 2, SuperAdmin = 2,
} }
export type Player = { export type Player = {
pid: number pid: number;
name: string name: string;
uid: number uid: number;
tid_skin: number tid_skin: number;
tid_cape: number tid_cape: number;
last_modified: string last_modified: string;
} };
export type Texture = { export type Texture = {
tid: number tid: number;
name: string name: string;
type: TextureType type: TextureType;
hash: string hash: string;
size: number size: number;
uploader: number uploader: number;
public: boolean public: boolean;
upload_at: string upload_at: string;
likes: number likes: number;
} };
export const enum TextureType { export const enum TextureType {
Steve = 'steve', Steve = 'steve',
Alex = 'alex', Alex = 'alex',
Cape = 'cape', Cape = 'cape',
} }
export type ClosetItem = Texture & { export type ClosetItem = Texture & {
pivot: { user_uid: number; texture_tid: number; item_name: string } pivot: {user_uid: number; texture_tid: number; item_name: string};
} };
export type Paginator<T> = { export type Paginator<T> = {
data: T[] data: T[];
current_page: number current_page: number;
last_page: number last_page: number;
from: number from: number;
to: number to: number;
total: number total: number;
} };

View File

@ -1,68 +1,68 @@
export default { export default {
admin: { admin: {
players: { players: {
delete: (player: number) => `/admin/players/${player}`, delete: (player: number) => `/admin/players/${player}`,
list: () => '/admin/players/list' as const, list: () => '/admin/players/list' as const,
name: (player: number) => `/admin/players/${player}/name`, name: (player: number) => `/admin/players/${player}/name`,
owner: (player: number) => `/admin/players/${player}/owner`, owner: (player: number) => `/admin/players/${player}/owner`,
texture: (player: number) => `/admin/players/${player}/textures`, texture: (player: number) => `/admin/players/${player}/textures`,
}, },
users: { users: {
delete: (user: number) => `/admin/users/${user}`, delete: (user: number) => `/admin/users/${user}`,
email: (user: number) => `/admin/users/${user}/email`, email: (user: number) => `/admin/users/${user}/email`,
list: () => '/admin/users/list' as const, list: () => '/admin/users/list' as const,
nickname: (user: number) => `/admin/users/${user}/nickname`, nickname: (user: number) => `/admin/users/${user}/nickname`,
password: (user: number) => `/admin/users/${user}/password`, password: (user: number) => `/admin/users/${user}/password`,
permission: (user: number) => `/admin/users/${user}/permission`, permission: (user: number) => `/admin/users/${user}/permission`,
score: (user: number) => `/admin/users/${user}/score`, score: (user: number) => `/admin/users/${user}/score`,
verification: (user: number) => `/admin/users/${user}/verification`, verification: (user: number) => `/admin/users/${user}/verification`,
}, },
}, },
auth: { auth: {
bind: () => '/auth/bind' as const, bind: () => '/auth/bind' as const,
forgot: () => '/auth/forgot' as const, forgot: () => '/auth/forgot' as const,
login: () => '/auth/login' as const, login: () => '/auth/login' as const,
logout: () => '/auth/logout' as const, logout: () => '/auth/logout' as const,
register: () => '/auth/register' as const, register: () => '/auth/register' as const,
reset: (uid: number) => `/auth/reset/${uid}`, reset: (uid: number) => `/auth/reset/${uid}`,
verify: (uid: number) => `/auth/verify/${uid}`, verify: (uid: number) => `/auth/verify/${uid}`,
}, },
skinlib: { skinlib: {
home: () => '/skinlib' as const, home: () => '/skinlib' as const,
info: (texture: number) => `/skinlib/info/${texture}`, info: (texture: number) => `/skinlib/info/${texture}`,
list: () => '/skinlib/list' as const, list: () => '/skinlib/list' as const,
show: (tid: number) => `/skinlib/show/${tid}`, show: (tid: number) => `/skinlib/show/${tid}`,
}, },
texture: { texture: {
delete: (texture: number) => `/texture/${texture}`, delete: (texture: number) => `/texture/${texture}`,
info: (texture: number) => `/texture/${texture}`, info: (texture: number) => `/texture/${texture}`,
name: (texture: number) => `/texture/${texture}/name`, name: (texture: number) => `/texture/${texture}/name`,
privacy: (texture: number) => `/texture/${texture}/privacy`, privacy: (texture: number) => `/texture/${texture}/privacy`,
type: (texture: number) => `/texture/${texture}/type`, type: (texture: number) => `/texture/${texture}/type`,
upload: () => '/texture' as const, upload: () => '/texture' as const,
}, },
user: { user: {
closet: { closet: {
add: () => '/user/closet' as const, add: () => '/user/closet' as const,
ids: () => '/user/closet/ids' as const, ids: () => '/user/closet/ids' as const,
list: () => '/user/closet/list' as const, list: () => '/user/closet/list' as const,
page: () => '/user/closet' as const, page: () => '/user/closet' as const,
remove: (tid: number) => `/user/closet/${tid}`, remove: (tid: number) => `/user/closet/${tid}`,
rename: (tid: number) => `/user/closet/${tid}`, rename: (tid: number) => `/user/closet/${tid}`,
}, },
home: () => '/user' as const, home: () => '/user' as const,
notification: (id: number) => `/user/notifications/${id}`, notification: (id: number) => `/user/notifications/${id}`,
player: { player: {
add: () => '/user/player' as const, add: () => '/user/player' as const,
clear: (player: number) => `/user/player/${player}/textures`, clear: (player: number) => `/user/player/${player}/textures`,
delete: (player: number) => `/user/player/${player}`, delete: (player: number) => `/user/player/${player}`,
list: () => '/user/player/list' as const, list: () => '/user/player/list' as const,
page: () => '/user/player' as const, page: () => '/user/player' as const,
rename: (player: number) => `/user/player/${player}/name`, rename: (player: number) => `/user/player/${player}/name`,
set: (player: number) => `/user/player/${player}/textures`, set: (player: number) => `/user/player/${player}/textures`,
}, },
profile: { avatar: () => '/user/profile/avatar' as const }, profile: {avatar: () => '/user/profile/avatar' as const},
score: () => '/user/score-info' as const, score: () => '/user/score-info' as const,
sign: () => '/user/sign' as const, sign: () => '/user/sign' as const,
}, },
} };

View File

@ -1,35 +1,33 @@
import JQuery from 'jquery' import type {ModalOptions, ModalResult} from './components/Modal';
import { ModalOptions, ModalResult } from './components/Modal' import type {Toast} from './scripts/toast';
import { Toast } from './scripts/toast'
declare global { declare global {
// eslint-disable-next-line no-redeclare let blessing: {
let blessing: { base_url: string;
base_url: string debug: boolean;
debug: boolean env: string;
env: string locale: string;
locale: string site_name: string;
site_name: string version: string;
version: string route: string;
route: string extra: Record<string, unknown>;
extra: any i18n: Record<string, unknown>;
i18n: object
fetch: { fetch: {
get(url: string, params?: object): Promise<object> get: (url: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
post(url: string, data?: object): Promise<object> post: (url: string, data?: Record<string, unknown>) => Promise<Record<string, unknown>>;
put(url: string, data?: object): Promise<object> put: (url: string, data?: Record<string, unknown>) => Promise<Record<string, unknown>>;
del(url: string, data?: object): Promise<object> del: (url: string, data?: Record<string, unknown>) => Promise<Record<string, unknown>>;
} };
event: { event: {
on(eventName: string, listener: Function): void on: (eventName: string, listener: (...args: any[]) => void) => void;
emit(eventName: string, payload: object): void emit: (eventName: string, payload: Record<string, unknown>) => void;
} };
notify: { notify: {
showModal(options?: ModalOptions): Promise<ModalResult> showModal: (options?: ModalOptions) => Promise<ModalResult>;
toast: Toast toast: Toast;
} };
} };
} }

View File

@ -1,3 +1,6 @@
@import 'spectre.css/src/spectre.scss';
@import '@/fonts/minecraft.css';
body { body {
height: 97vh; height: 97vh;
} }

View File

@ -1,19 +1,19 @@
export const enum Breakpoint { export const enum Breakpoint {
xs = 0, xs = 0,
sm = 576, sm = 576,
md = 768, md = 768,
lg = 992, lg = 992,
xl = 1200, xl = 1200,
} }
export function lessThan(breakpoint: Breakpoint): string { export function lessThan(breakpoint: Breakpoint): string {
return `@media (max-width: ${breakpoint}px)` return `@media (max-width: ${breakpoint}px)`;
} }
export function between(down: Breakpoint, up: Breakpoint): string { export function between(down: Breakpoint, up: Breakpoint): string {
return `@media (min-width: ${down}px) and (max-width: ${up}px)` return `@media (min-width: ${down}px) and (max-width: ${up}px)`;
} }
export function greaterThan(breakpoint: Breakpoint): string { export function greaterThan(breakpoint: Breakpoint): string {
return `@media (min-width: ${breakpoint}px)` return `@media (min-width: ${breakpoint}px)`;
} }

View File

@ -1,11 +1,11 @@
import { css } from '@emotion/react' import {css} from '@emotion/react';
export const pointerCursor = css` export const pointerCursor = css`
cursor: pointer; cursor: pointer;
` `;
export const center = css` export const center = css`
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
` `;

View File

@ -1,83 +1,79 @@
/* eslint-disable object-curly-newline */
import { fromEvent, merge, of, partition } from 'rxjs' import {
import { filter, map, pairwise } from 'rxjs/operators' fromEvent,
merge,
of,
partition,
} from 'rxjs';
import {filter, map, pairwise} from 'rxjs/operators';
export function registerNavbarPicker( export function registerNavbarPicker(
navbar: HTMLElement, navbar: HTMLElement,
picker: HTMLDivElement, picker: HTMLDivElement,
init: string, init: string,
): void { ): void {
const color$ = fromEvent(picker, 'click').pipe( const color$ = fromEvent(picker, 'click').pipe(
map((event) => event.target as HTMLElement), map(event => event.target as HTMLElement),
filter( filter((element): element is HTMLInputElement => element.tagName === 'INPUT'),
(element): element is HTMLInputElement => element.tagName === 'INPUT', map(element => element.value),
), );
map((element) => element.value),
)
merge(of(init), color$) merge(of(init), color$)
.pipe(pairwise()) .pipe(pairwise())
.subscribe(([previous, current]) => { .subscribe(([previous, current]) => {
navbar.classList.replace(`navbar-${previous}`, `navbar-${current}`) navbar.classList.replace(`navbar-${previous}`, `navbar-${current}`);
}) });
const [light$, dark$] = partition(color$, (color) => const [light$, dark$] = partition(color$, color =>
['light', 'warning', 'white', 'orange', 'lime'].includes(color), ['light', 'warning', 'white', 'orange', 'lime'].includes(color));
) light$.subscribe(() => {
light$.subscribe(() => { // DO NOT use `classList.replace`.
// DO NOT use `classList.replace`. navbar.classList.remove('navbar-dark');
navbar.classList.remove('navbar-dark') navbar.classList.add('navbar-light');
navbar.classList.add('navbar-light') });
}) dark$.subscribe(() => {
dark$.subscribe(() => { // DO NOT use `classList.replace`.
// DO NOT use `classList.replace`. navbar.classList.remove('navbar-light');
navbar.classList.remove('navbar-light') navbar.classList.add('navbar-dark');
navbar.classList.add('navbar-dark') });
})
} }
const navbar = document.querySelector<HTMLElement>('.wrapper > nav') const navbar = document.querySelector<HTMLElement>('.wrapper > nav');
const picker = document.querySelector<HTMLDivElement>('#navbar-color-picker') const picker = document.querySelector<HTMLDivElement>('#navbar-color-picker');
/* istanbul ignore next */
if (navbar && picker) { if (navbar && picker) {
registerNavbarPicker(navbar, picker, blessing.extra.navbar || 'white') registerNavbarPicker(navbar, picker, blessing.extra.navbar as string || 'white');
} }
export function registerSidebarPicker( export function registerSidebarPicker(
sidebar: HTMLElement, sidebar: HTMLElement,
{ dark, light }: { dark: HTMLDivElement; light: HTMLDivElement }, {dark, light}: {dark: HTMLDivElement; light: HTMLDivElement},
init: string, init: string,
): void { ): void {
const color$ = merge( const color$ = merge(
fromEvent(dark, 'click'), fromEvent(dark, 'click'),
fromEvent(light, 'click'), fromEvent(light, 'click'),
).pipe( ).pipe(
map((event) => event.target as HTMLElement), map(event => event.target as HTMLElement),
filter( filter((element): element is HTMLInputElement => element.tagName === 'INPUT'),
(element): element is HTMLInputElement => element.tagName === 'INPUT', map(element => element.value),
), );
map((element) => element.value),
)
merge(of(init), color$) merge(of(init), color$)
.pipe(pairwise()) .pipe(pairwise())
.subscribe(([previous, current]) => { .subscribe(([previous, current]) => {
sidebar.classList.replace(`sidebar-${previous}`, `sidebar-${current}`) sidebar.classList.replace(`sidebar-${previous}`, `sidebar-${current}`);
}) });
} }
const sidebar = document.querySelector<HTMLElement>('.main-sidebar') const sidebar = document.querySelector<HTMLElement>('.main-sidebar');
const darkPicker = document.querySelector<HTMLDivElement>( const darkPicker = document.querySelector<HTMLDivElement>('#sidebar-dark-picker');
'#sidebar-dark-picker', const lightPicker = document.querySelector<HTMLDivElement>('#sidebar-light-picker');
)
const lightPicker = document.querySelector<HTMLDivElement>(
'#sidebar-light-picker',
)
/* istanbul ignore next */
if (sidebar && darkPicker && lightPicker) { if (sidebar && darkPicker && lightPicker) {
registerSidebarPicker( registerSidebarPicker(
sidebar, sidebar,
{ dark: darkPicker, light: lightPicker }, {dark: darkPicker, light: lightPicker},
blessing.extra.sidebar || 'dark-primary', blessing.extra.sidebar as string || 'dark-primary',
) );
} }

View File

@ -1,120 +1,116 @@
import * as echarts from 'echarts/core' import {LineChart} from 'echarts/charts';
import { SVGRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { import {
DataZoomComponent, DataZoomComponent,
GridComponent, GridComponent,
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
} from 'echarts/components' } from 'echarts/components';
import { get } from '../../scripts/net' import * as echarts from 'echarts/core';
import {SVGRenderer} from 'echarts/renderers';
import {get} from '../../scripts/net';
interface ChartData { type ChartData = {
labels: string[] labels: string[];
xAxis: string[] xAxis: string[];
data: number[][] data: number[][];
} };
interface SingleChartData { type SingleChartData = {
label: string label: string;
xAxis: string[] xAxis: string[];
data: number[] data: number[];
} };
echarts.use([ echarts.use([
SVGRenderer, SVGRenderer,
LineChart, LineChart,
DataZoomComponent, DataZoomComponent,
GridComponent, GridComponent,
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
]) ]);
async function main() { async function main() {
const elUsersRegistration = document.querySelector<HTMLDivElement>( const elementUsersRegistration = document.querySelector<HTMLDivElement>('#chart-users-registration');
'#chart-users-registration', const elementTexturesUpload = document.querySelector<HTMLDivElement>('#chart-textures-upload');
) if (!elementUsersRegistration || !elementTexturesUpload) {
const elTexturesUpload = document.querySelector<HTMLDivElement>( return;
'#chart-textures-upload', }
)
if (!elUsersRegistration || !elTexturesUpload) {
return
}
const isDarkMode = document.body.classList.contains('dark-mode') const isDarkMode = document.body.classList.contains('dark-mode');
const textColor = isDarkMode ? '#fff' : '#000' const textColor = isDarkMode ? '#fff' : '#000';
const chartData: ChartData = await get('/admin/chart') const chartData: ChartData = await get('/admin/chart');
createLineChart( createLineChart(
elUsersRegistration, elementUsersRegistration,
isDarkMode ? '#3498db' : '#17a2b8', isDarkMode ? '#3498db' : '#17a2b8',
textColor, textColor,
{ {
label: chartData.labels[0]!, label: chartData.labels[0],
xAxis: chartData.xAxis, xAxis: chartData.xAxis,
data: chartData.data[0]!, data: chartData.data[0],
}, },
) );
createLineChart(elTexturesUpload, '#6f42c1', textColor, { createLineChart(elementTexturesUpload, '#6f42c1', textColor, {
label: chartData.labels[1]!, label: chartData.labels[1],
xAxis: chartData.xAxis, xAxis: chartData.xAxis,
data: chartData.data[1]!, data: chartData.data[1],
}) });
} }
function createLineChart( function createLineChart(
el: HTMLDivElement, element: HTMLDivElement,
color: string, color: string,
textColor: string, textColor: string,
data: SingleChartData, data: SingleChartData,
) { ) {
const chart = echarts.init(el) const chart = echarts.init(element);
chart.setOption({ chart.setOption({
title: { title: {
text: data.label, text: data.label,
textStyle: { textStyle: {
color: textColor, color: textColor,
}, },
}, },
textStyle: { textStyle: {
color: textColor, color: textColor,
}, },
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
}, },
dataZoom: [ dataZoom: [
{ type: 'inside', start: 75 }, {type: 'inside', start: 75},
{ type: 'slider', start: 75 }, {type: 'slider', start: 75},
], ],
xAxis: [ xAxis: [
{ {
type: 'category', type: 'category',
boundaryGap: false, boundaryGap: false,
data: data.xAxis, data: data.xAxis,
}, },
], ],
yAxis: [ yAxis: [
{ {
type: 'value', type: 'value',
minInterval: 1, minInterval: 1,
boundaryGap: false, boundaryGap: false,
}, },
], ],
series: [ series: [
{ {
name: data.label, name: data.label,
type: 'line', type: 'line',
itemStyle: { itemStyle: {
color, color,
}, },
areaStyle: { areaStyle: {
color, color,
}, },
data: data.data, data: data.data,
smooth: true, smooth: true,
}, },
], ],
}) });
} }
main() void main();

View File

@ -1,163 +1,170 @@
import React from 'react'
import { t } from '@/scripts/i18n'
import { showModal } from '@/scripts/notify'
import type { Player } from '@/scripts/types'
import { Box } from './styles'
import clsx from 'clsx'
interface Props { import type {Player} from '@/scripts/types';
player: Player import {t} from '@/scripts/i18n';
onUpdateName(): void import {showModal} from '@/scripts/notify';
onUpdateOwner(): void import clsx from 'clsx';
onUpdateTexture(): void import {Box} from './styles';
onDelete(): void
}
const Card: React.FC<Props> = (props) => { type Props = {
const { player } = props readonly player: Player;
onUpdateName: () => void;
onUpdateOwner: () => void;
onUpdateTexture: () => void;
onDelete: () => void;
};
const handlePreviewTextures = () => { const Card: React.FC<Props> = props => {
const skinPreview = `${blessing.base_url}/preview/${player.tid_skin}` const {player} = props;
const skinPreviewPNG = `${skinPreview}?png`
const capePreview = `${blessing.base_url}/preview/${player.tid_cape}`
const capePreviewPNG = `${capePreview}?png`
showModal({ const handlePreviewTextures = () => {
mode: 'alert', const skinPreview = `${blessing.base_url}/preview/${player.tid_skin}`;
title: t('general.player.previews'), const skinPreviewPNG = `${skinPreview}?png`;
children: ( const capePreview = `${blessing.base_url}/preview/${player.tid_cape}`;
<div className="row"> const capePreviewPNG = `${capePreview}?png`;
<div className="col-6 d-flex justify-content-center">
{player.tid_skin > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`}
target="_blank"
>
<picture>
<source srcSet={skinPreview} type="image/webp" />
<img
src={skinPreviewPNG}
alt={`${player.name} - ${t('general.skin')}`}
width="128"
/>
</picture>
</a>
)}
</div>
<div className="col-6 d-flex justify-content-center">
{player.tid_cape > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`}
target="_blank"
>
<picture>
<source srcSet={capePreview} type="image/webp" />
<img
src={capePreviewPNG}
alt={`${player.name} - ${t('general.cape')}`}
width="128"
/>
</picture>
</a>
)}
</div>
</div>
),
})
}
const isDarkMode = document.body.classList.contains('dark-mode') showModal({
mode: 'alert',
title: t('general.player.previews'),
children: (
<div className='row'>
<div className='col-6 d-flex justify-content-center'>
{player.tid_skin > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`}
target='_blank'
rel='noreferrer'
>
<picture>
<source srcSet={skinPreview} type='image/webp'/>
<img
src={skinPreviewPNG}
alt={`${player.name} - ${t('general.skin')}`}
width='128'
/>
</picture>
</a>
)}
</div>
<div className='col-6 d-flex justify-content-center'>
{player.tid_cape > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`}
target='_blank'
rel='noreferrer'
>
<picture>
<source srcSet={capePreview} type='image/webp'/>
<img
src={capePreviewPNG}
alt={`${player.name} - ${t('general.cape')}`}
width='128'
/>
</picture>
</a>
)}
</div>
</div>
),
});
};
const avatar = `${blessing.base_url}/avatar/player/${player.name}` const isDarkMode = document.body.classList.contains('dark-mode');
const avatarPNG = `${avatar}?png`
return ( const avatar = `${blessing.base_url}/avatar/player/${player.name}`;
<Box className={clsx('info-box', { 'bg-gray-dark': isDarkMode })}> const avatarPNG = `${avatar}?png`;
<div className="info-box-icon">
<picture>
<source srcSet={avatar} type="image/webp" />
<img className="bs-avatar" src={avatarPNG} />
</picture>
</div>
<div className="info-box-content">
<div className="row">
<div className="col-10">
<b>{player.name}</b>
</div>
<div className="col-2">
<div className="float-right dropdown">
<a
className="text-gray"
href="#"
data-toggle="dropdown"
aria-expanded="false"
>
<i className="fas fa-cog"></i>
</a>
<div className="dropdown-menu dropdown-menu-right">
<a
href="#"
className="dropdown-item"
onClick={handlePreviewTextures}
>
<i className="fas fa-eye mr-2"></i>
{t('general.player.previews')}
</a>
<div className="dropdown-divider"></div>
<a
href="#"
className="dropdown-item"
onClick={props.onUpdateName}
>
<i className="fas fa-signature mr-2"></i>
{t('admin.changePlayerName')}
</a>
<a
href="#"
className="dropdown-item"
onClick={props.onUpdateOwner}
>
<i className="fas fa-user-edit mr-2"></i>
{t('admin.changeOwner')}
</a>
<a
href="#"
className="dropdown-item"
onClick={props.onUpdateTexture}
>
<i className="fas fa-tshirt mr-2"></i>
{t('admin.changeTexture')}
</a>
<div className="dropdown-divider"></div>
<a
href="#"
className="dropdown-item dropdown-item-danger"
onClick={props.onDelete}
>
<i className="fas fa-trash mr-2"></i>
{t('admin.deletePlayer')}
</a>
</div>
</div>
</div>
</div>
<div>
<div>
<span className="mr-2">PID: {player.pid}</span>
<span>
{t('general.player.owner')}: {player.uid}
</span>
</div>
<div>
<small className="text-gray">
{`${t('general.player.last-modified')}: `}
{player.last_modified}
</small>
</div>
</div>
</div>
</Box>
)
}
export default Card return (
<Box className={clsx('info-box', {'bg-gray-dark': isDarkMode})}>
<div className='info-box-icon'>
<picture>
<source srcSet={avatar} type='image/webp'/>
<img className='bs-avatar' src={avatarPNG}/>
</picture>
</div>
<div className='info-box-content'>
<div className='row'>
<div className='col-10'>
<b>{player.name}</b>
</div>
<div className='col-2'>
<div className='float-right dropdown'>
<a
className='text-gray'
href='#'
data-toggle='dropdown'
aria-expanded='false'
>
<i className='fas fa-cog'/>
</a>
<div className='dropdown-menu dropdown-menu-right'>
<a
href='#'
className='dropdown-item'
onClick={handlePreviewTextures}
>
<i className='fas fa-eye mr-2'/>
{t('general.player.previews')}
</a>
<div className='dropdown-divider'/>
<a
href='#'
className='dropdown-item'
onClick={props.onUpdateName}
>
<i className='fas fa-signature mr-2'/>
{t('admin.changePlayerName')}
</a>
<a
href='#'
className='dropdown-item'
onClick={props.onUpdateOwner}
>
<i className='fas fa-user-edit mr-2'/>
{t('admin.changeOwner')}
</a>
<a
href='#'
className='dropdown-item'
onClick={props.onUpdateTexture}
>
<i className='fas fa-tshirt mr-2'/>
{t('admin.changeTexture')}
</a>
<div className='dropdown-divider'/>
<a
href='#'
className='dropdown-item dropdown-item-danger'
onClick={props.onDelete}
>
<i className='fas fa-trash mr-2'/>
{t('admin.deletePlayer')}
</a>
</div>
</div>
</div>
</div>
<div>
<div>
<span className='mr-2'>
PID:
{player.pid}
</span>
<span>
{t('general.player.owner')}
:
{player.uid}
</span>
</div>
<div>
<small className='text-gray'>
{`${t('general.player.last-modified')}: `}
{player.last_modified}
</small>
</div>
</div>
</div>
</Box>
);
};
export default Card;

View File

@ -1,37 +1,36 @@
import React from 'react' import styled from '@emotion/styled';
import styled from '@emotion/styled' import clsx from 'clsx';
import Skeleton from 'react-loading-skeleton' import Skeleton from 'react-loading-skeleton';
import { Box } from './styles' import {Box} from './styles';
import clsx from 'clsx'
const isDarkMode = document.body.classList.contains('dark-mode') const isDarkMode = document.body.classList.contains('dark-mode');
const ShrinkedSkeleton = styled(Skeleton)<{ width?: string }>` const ShrinkedSkeleton = styled(Skeleton)<{width?: string}>`
width: ${(props) => props.width}; width: ${props => props.width};
` `;
const LoadingCard: React.FC = () => ( export default function LoadingCard() {
<Box className={clsx('info-box', { 'bg-gray-dark': isDarkMode })}> return (
<div className="info-box-icon"> <Box className={clsx('info-box', {'bg-gray-dark': isDarkMode})}>
<Skeleton circle height={50} width={50} /> <div className='info-box-icon'>
</div> <Skeleton circle height={50} width={50}/>
<div className="info-box-content"> </div>
<div className="row"> <div className='info-box-content'>
<div className="col-10"> <div className='row'>
<ShrinkedSkeleton width="120px" /> <div className='col-10'>
</div> <ShrinkedSkeleton width='120px'/>
<div className="col-2"></div> </div>
</div> <div className='col-2'/>
<div> </div>
<div> <div>
<ShrinkedSkeleton width="150px" /> <div>
</div> <ShrinkedSkeleton width='150px'/>
<div> </div>
<ShrinkedSkeleton width="180px" /> <div>
</div> <ShrinkedSkeleton width='180px'/>
</div> </div>
</div> </div>
</Box> </div>
) </Box>
);
export default LoadingCard }

View File

@ -1,17 +1,16 @@
import React from 'react' import styled from '@emotion/styled';
import styled from '@emotion/styled' import Skeleton from 'react-loading-skeleton';
import Skeleton from 'react-loading-skeleton'
const ThickSkeleton = styled(Skeleton)` const ThickSkeleton = styled(Skeleton)`
line-height: 2; line-height: 2;
` `;
const LoadingRow: React.FC = () => ( export default function LoadingRow() {
<tr> return (
<td colSpan={6}> <tr>
<ThickSkeleton /> <td colSpan={6}>
</td> <ThickSkeleton/>
</tr> </td>
) </tr>
);
export default LoadingRow }

View File

@ -1,84 +1,84 @@
import React, { useState } from 'react' import Modal from '@/components/Modal';
import { t } from '@/scripts/i18n' import {t} from '@/scripts/i18n';
import { TextureType } from '@/scripts/types' import {TextureType} from '@/scripts/types';
import Modal from '@/components/Modal' import {useState} from 'react';
interface Props { type Props = {
open: boolean readonly open: boolean;
onSubmit(type: 'skin' | 'cape', tid: number): void onSubmit: (type: 'skin' | 'cape', tid: number) => void;
onClose(): void onClose: () => void;
} };
const ModalUpdateTexture: React.FC<Props> = (props) => { const ModalUpdateTexture: React.FC<Props> = props => {
const [type, setType] = useState<'skin' | 'cape'>('skin') const [type, setType] = useState<'skin' | 'cape'>('skin');
const [tid, setTid] = useState('') const [tid, setTid] = useState('');
const handleTypeChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleTypeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setType(event.target.value as 'skin' | 'cape') setType(event.target.value as 'skin' | 'cape');
} };
const handleTidChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleTidChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setTid(event.target.value) setTid(event.target.value);
} };
const handleConfirm = () => { const handleConfirm = () => {
props.onSubmit(type, Number.parseInt(tid)) props.onSubmit(type, Number.parseInt(tid));
setType('skin') setType('skin');
setTid('') setTid('');
} };
const handleClose = () => { const handleClose = () => {
setType('skin') setType('skin');
setTid('') setTid('');
props.onClose() props.onClose();
} };
return ( return (
<Modal <Modal
show={props.open} center
center show={props.open}
title={t('admin.changeTexture')} title={t('admin.changeTexture')}
onConfirm={handleConfirm} onConfirm={handleConfirm}
onClose={handleClose} onClose={handleClose}
> >
<div className="form-group"> <div className='form-group'>
<label>{t('admin.textureType')}</label> <label>{t('admin.textureType')}</label>
<div> <div>
<label className="mr-5"> <label className='mr-5'>
<input <input
className="mr-1" className='mr-1'
type="radio" type='radio'
value="skin" value='skin'
checked={type === 'skin'} checked={type === 'skin'}
onChange={handleTypeChange} onChange={handleTypeChange}
/> />
{t('general.skin')} {t('general.skin')}
</label> </label>
<label> <label>
<input <input
className="mr-1" className='mr-1'
type="radio" type='radio'
value="cape" value='cape'
checked={type === TextureType.Cape} checked={type === TextureType.Cape}
onChange={handleTypeChange} onChange={handleTypeChange}
/> />
{t('general.cape')} {t('general.cape')}
</label> </label>
</div> </div>
</div> </div>
<div className="form-group"> <div className='form-group'>
<label htmlFor="update-texture-tid">TID</label> <label htmlFor='update-texture-tid'>TID</label>
<input <input
type="number" type='number'
id="update-texture-tid" id='update-texture-tid'
className="form-control" className='form-control'
placeholder={t('admin.pidNotice')} placeholder={t('admin.pidNotice')}
value={tid} value={tid}
onChange={handleTidChange} onChange={handleTidChange}
/> />
</div> </div>
</Modal> </Modal>
) );
} };
export default ModalUpdateTexture export default ModalUpdateTexture;

View File

@ -1,81 +1,83 @@
import React from 'react'
import { t } from '@/scripts/i18n'
import type { Player } from '@/scripts/types'
import ButtonEdit from '@/components/ButtonEdit'
interface Props { import type {Player} from '@/scripts/types';
player: Player import ButtonEdit from '@/components/ButtonEdit';
onUpdateName(): void import {t} from '@/scripts/i18n';
onUpdateOwner(): void
onUpdateTexture(): void
onDelete(): void
}
const Row: React.FC<Props> = (props) => { type Props = {
const { player } = props readonly player: Player;
onUpdateName: () => void;
onUpdateOwner: () => void;
onUpdateTexture: () => void;
onDelete: () => void;
};
return ( const Row: React.FC<Props> = props => {
<tr> const {player} = props;
<td>{player.pid}</td>
<td>
{player.name}
<span className="ml-1">
<ButtonEdit
title={t('admin.changePlayerName')}
onClick={props.onUpdateName}
/>
</span>
</td>
<td>
{player.uid}
<span className="ml-1">
<ButtonEdit
title={t('admin.changeOwner')}
onClick={props.onUpdateOwner}
/>
</span>
</td>
<td>
{player.tid_skin > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`}
target="_blank"
className="mr-1"
>
<img
src={`${blessing.base_url}/preview/${player.tid_skin}`}
alt={`${player.name} - ${t('general.skin')}`}
width="64"
/>
</a>
)}
{player.tid_cape > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`}
target="_blank"
>
<img
src={`${blessing.base_url}/preview/${player.tid_cape}`}
alt={`${player.name} - ${t('general.cape')}`}
width="64"
/>
</a>
)}
</td>
<td>{player.last_modified}</td>
<td className="d-flex flex-wrap">
<button
className="btn btn-default mr-2"
onClick={props.onUpdateTexture}
>
{t('admin.changeTexture')}
</button>
<button className="btn btn-danger" onClick={props.onDelete}>
{t('admin.deletePlayer')}
</button>
</td>
</tr>
)
}
export default Row return (
<tr>
<td>{player.pid}</td>
<td>
{player.name}
<span className='ml-1'>
<ButtonEdit
title={t('admin.changePlayerName')}
onClick={props.onUpdateName}
/>
</span>
</td>
<td>
{player.uid}
<span className='ml-1'>
<ButtonEdit
title={t('admin.changeOwner')}
onClick={props.onUpdateOwner}
/>
</span>
</td>
<td>
{player.tid_skin > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`}
target='_blank'
className='mr-1'
rel='noreferrer'
>
<img
src={`${blessing.base_url}/preview/${player.tid_skin}`}
alt={`${player.name} - ${t('general.skin')}`}
width='64'
/>
</a>
)}
{player.tid_cape > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`}
target='_blank'
rel='noreferrer'
>
<img
src={`${blessing.base_url}/preview/${player.tid_cape}`}
alt={`${player.name} - ${t('general.cape')}`}
width='64'
/>
</a>
)}
</td>
<td>{player.last_modified}</td>
<td className='d-flex flex-wrap'>
<button
className='btn btn-default mr-2'
onClick={props.onUpdateTexture}
>
{t('admin.changeTexture')}
</button>
<button className='btn btn-danger' onClick={props.onDelete}>
{t('admin.deletePlayer')}
</button>
</td>
</tr>
);
};
export default Row;

View File

@ -1,271 +1,276 @@
import React, { useState, useEffect, useLayoutEffect } from 'react' import type {Paginator, Player} from '@/scripts/types';
import { hot } from 'react-hot-loader/root' import Pagination from '@/components/Pagination';
import { useImmer } from 'use-immer' import useIsLargeScreen from '@/scripts/hooks/useIsLargeScreen';
import useIsLargeScreen from '@/scripts/hooks/useIsLargeScreen' import {t} from '@/scripts/i18n';
import { t } from '@/scripts/i18n' import * as fetch from '@/scripts/net';
import * as fetch from '@/scripts/net' import {showModal, toast} from '@/scripts/notify';
import type { Player, Paginator } from '@/scripts/types' import urls from '@/scripts/urls';
import { toast, showModal } from '@/scripts/notify' import {useEffect, useLayoutEffect, useState} from 'react';
import urls from '@/scripts/urls' import {useImmer} from 'use-immer';
import Pagination from '@/components/Pagination' import Header from '../UsersManagement/Header';
import Header from '../UsersManagement/Header' import Card from './Card';
import Card from './Card' import LoadingCard from './LoadingCard';
import LoadingCard from './LoadingCard' import LoadingRow from './LoadingRow';
import Row from './Row' import ModalUpdateTexture from './ModalUpdateTexture';
import LoadingRow from './LoadingRow' import Row from './Row';
import ModalUpdateTexture from './ModalUpdateTexture'
const PlayersManagement: React.FC = () => { function PlayersManagement() {
const [players, setPlayers] = useImmer<Player[]>([]) const [players, setPlayers] = useImmer<Player[]>([]);
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1) const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false);
const isLargeScreen = useIsLargeScreen() const isLargeScreen = useIsLargeScreen();
const [isTableMode, setIsTableMode] = useState(false) const [isTableMode, setIsTableMode] = useState(false);
const [query, setQuery] = useState('') const [query, setQuery] = useState('');
const [textureUpdating, setTextureUpdating] = useState(-1) const [textureUpdating, setTextureUpdating] = useState(-1);
useLayoutEffect(() => { useLayoutEffect(() => {
if (isLargeScreen) { if (isLargeScreen) {
setIsTableMode(true) setIsTableMode(true);
} }
}, [isLargeScreen]) }, [isLargeScreen]);
const getPlayers = async () => { const getPlayers = async () => {
setIsLoading(true) setIsLoading(true);
const { data, last_page }: Paginator<Player> = await fetch.get( const {data, last_page}: Paginator<Player> = await fetch.get(
urls.admin.players.list(), urls.admin.players.list(),
{ {
q: query, q: query,
page, page: page.toString(),
}, },
) );
setTotalPages(last_page) setTotalPages(last_page);
setPlayers(() => data) setPlayers(() => data);
setIsLoading(false) setIsLoading(false);
} };
useEffect(() => { useEffect(() => {
getPlayers() getPlayers();
}, [page]) }, [page]);
const handleModeChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleModeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setIsTableMode(event.target.value === 'table') setIsTableMode(event.target.value === 'table');
} };
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value) setQuery(event.target.value);
} };
const handleSubmitQuery = (event: React.FormEvent) => { const handleSubmitQuery = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault();
getPlayers() getPlayers();
} };
const handleUpdateName = async (player: Player, index: number) => { const handleUpdateName = async (player: Player, index: number) => {
let name: string let name: string;
try { try {
const { value } = await showModal({ const {value} = await showModal({
mode: 'prompt', mode: 'prompt',
text: t('admin.changePlayerNameNotice'), text: t('admin.changePlayerNameNotice'),
input: player.name, input: player.name,
validator: (value: string) => { validator(value: string) {
if (!value) { if (!value) {
return t('admin.emptyPlayerName') return t('admin.emptyPlayerName');
} }
}, },
}) });
name = value name = value;
} catch { } catch {
return return;
} }
const { code, message } = await fetch.put<fetch.ResponseBody>( const {code, message} = await fetch.put<fetch.ResponseBody>(
urls.admin.players.name(player.pid), urls.admin.players.name(player.pid),
{ player_name: name }, {player_name: name},
) );
if (code === 0) { if (code === 0) {
toast.success(message) toast.success(message);
setPlayers((players) => { setPlayers(players => {
players[index]!.name = name players[index].name = name;
}) });
} else { } else {
toast.error(message) toast.error(message);
} }
} };
const handleUpdateOwner = async (player: Player, index: number) => { const handleUpdateOwner = async (player: Player, index: number) => {
let uid: number let uid: number;
try { try {
const { value } = await showModal({ const {value} = await showModal({
mode: 'prompt', mode: 'prompt',
text: t('admin.changePlayerOwner'), text: t('admin.changePlayerOwner'),
input: player.uid.toString(), input: player.uid.toString(),
inputMode: 'numeric', inputMode: 'numeric',
}) });
uid = Number.parseInt(value) uid = Number.parseInt(value);
} catch { } catch {
return return;
} }
const { code, message } = await fetch.put<fetch.ResponseBody>( const {code, message} = await fetch.put<fetch.ResponseBody>(
urls.admin.players.owner(player.pid), urls.admin.players.owner(player.pid),
{ uid }, {uid},
) );
if (code === 0) { if (code === 0) {
toast.success(message) toast.success(message);
setPlayers((players) => { setPlayers(players => {
players[index]!.uid = uid players[index].uid = uid;
}) });
} else { } else {
toast.error(message) toast.error(message);
} }
} };
const handleCloseModalUpdateTexture = () => setTextureUpdating(-1) const handleCloseModalUpdateTexture = () => {
setTextureUpdating(-1);
};
const handleUpdateTexture = async (type: 'skin' | 'cape', tid: number) => { const handleUpdateTexture = async (type: 'skin' | 'cape', tid: number) => {
const { code, message } = await fetch.put<fetch.ResponseBody>( const {code, message} = await fetch.put<fetch.ResponseBody>(
urls.admin.players.texture(players[textureUpdating]!.pid), urls.admin.players.texture(players[textureUpdating].pid),
{ type, tid }, {type, tid},
) );
if (code === 0) { if (code === 0) {
toast.success(message) toast.success(message);
setPlayers((players) => { setPlayers(players => {
const field = `tid_${type}` as const const field = `tid_${type}` as const;
players[textureUpdating]![field] = tid players[textureUpdating][field] = tid;
}) });
} else { } else {
toast.error(message) toast.error(message);
} }
} };
const handleDelete = async (player: Player) => { const handleDelete = async (player: Player) => {
try { try {
await showModal({ await showModal({
text: t('admin.deletePlayerNotice'), text: t('admin.deletePlayerNotice'),
okButtonType: 'danger', okButtonType: 'danger',
}) });
} catch { } catch {
return return;
} }
const { code, message } = await fetch.del<fetch.ResponseBody>( const {code, message} = await fetch.del<fetch.ResponseBody>(urls.admin.players.delete(player.pid));
urls.admin.players.delete(player.pid), if (code === 0) {
) setPlayers(players => players.filter(({pid}) => pid !== player.pid));
if (code === 0) { toast.success(message);
setPlayers((players) => players.filter(({ pid }) => pid !== player.pid)) } else {
toast.success(message) toast.error(message);
} else { }
toast.error(message) };
}
}
return ( return (
<div className="card"> <div className='card'>
<Header className="card-header"> <Header className='card-header'>
<form className="input-group" onSubmit={handleSubmitQuery}> <form className='input-group' onSubmit={handleSubmitQuery}>
<input <input
type="text" type='text'
inputMode="search" inputMode='search'
className="form-control" className='form-control'
title={t('vendor.datatable.search')} title={t('vendor.datatable.search')}
value={query} value={query}
onChange={handleQueryChange} onChange={handleQueryChange}
/> />
<div className="input-group-append"> <div className='input-group-append'>
<button className="btn btn-primary" type="submit"> <button className='btn btn-primary' type='submit'>
{t('vendor.datatable.search')} {t('vendor.datatable.search')}
</button> </button>
</div> </div>
</form> </form>
<div className="btn-group btn-group-toggle"> <div className='btn-group btn-group-toggle'>
<label <label
className={`btn btn-secondary ${isTableMode ? 'active' : ''}`} className={`btn btn-secondary ${isTableMode ? 'active' : ''}`}
title="Table Mode" title='Table Mode'
> >
<input <input
type="radio" type='radio'
value="table" value='table'
checked={isTableMode} checked={isTableMode}
onChange={handleModeChange} onChange={handleModeChange}
/> />
<i className="fas fa-list"></i> <i className='fas fa-list'/>
</label> </label>
<label <label
className={`btn btn-secondary ${isTableMode ? '' : 'active'}`} className={`btn btn-secondary ${isTableMode ? '' : 'active'}`}
title="Card Mode" title='Card Mode'
> >
<input <input
type="radio" type='radio'
value="card" value='card'
checked={!isTableMode} checked={!isTableMode}
onChange={handleModeChange} onChange={handleModeChange}
/> />
<i className="fas fa-grip-vertical"></i> <i className='fas fa-grip-vertical'/>
</label> </label>
</div> </div>
</Header> </Header>
{players.length === 0 && !isLoading ? ( {players.length === 0 && !isLoading
<div className="card-body text-center">{t('general.noResult')}</div> ? <div className='card-body text-center'>{t('general.noResult')}</div>
) : isTableMode ? ( : isTableMode
<div className="card-body table-responsive p-0"> ? (
<table className={`table ${isLoading ? '' : 'table-striped'}`}> <div className='card-body table-responsive p-0'>
<thead> <table className={`table ${isLoading ? '' : 'table-striped'}`}>
<tr> <thead>
<th>PID</th> <tr>
<th>{t('general.player.player-name')}</th> <th>PID</th>
<th>{t('general.player.owner')}</th> <th>{t('general.player.player-name')}</th>
<th>{t('general.player.previews')}</th> <th>{t('general.player.owner')}</th>
<th>{t('general.player.last-modified')}</th> <th>{t('general.player.previews')}</th>
<th>{t('admin.operationsTitle')}</th> <th>{t('general.player.last-modified')}</th>
</tr> <th>{t('admin.operationsTitle')}</th>
</thead> </tr>
<tbody> </thead>
{isLoading <tbody>
? new Array(10).fill(null).map((_, i) => <LoadingRow key={i} />) {isLoading
: players.map((player, i) => ( ? Array.from({length: 10}).fill(null).map((_, i) => <LoadingRow key={i}/>)
<Row : players.map((player, i) => (
key={player.pid} <Row
player={player} key={player.pid}
onUpdateName={() => handleUpdateName(player, i)} player={player}
onUpdateOwner={() => handleUpdateOwner(player, i)} onUpdateName={async () => handleUpdateName(player, i)}
onUpdateTexture={() => setTextureUpdating(i)} onUpdateOwner={async () => handleUpdateOwner(player, i)}
onDelete={() => handleDelete(player)} onUpdateTexture={() => {
/> setTextureUpdating(i);
))} }}
</tbody> onDelete={async () => handleDelete(player)}
</table> />
</div> ))}
) : ( </tbody>
<div className="card-body d-flex flex-wrap"> </table>
{isLoading </div>
? new Array(10).fill(null).map((_, i) => <LoadingCard key={i} />) )
: players.map((player, i) => ( : (
<Card <div className='card-body d-flex flex-wrap'>
key={player.pid} {isLoading
player={player} ? Array.from({length: 10}).fill(null).map((_, i) => <LoadingCard key={i}/>)
onUpdateName={() => handleUpdateName(player, i)} : players.map((player, i) => (
onUpdateOwner={() => handleUpdateOwner(player, i)} <Card
onUpdateTexture={() => setTextureUpdating(i)} key={player.pid}
onDelete={() => handleDelete(player)} player={player}
/> onUpdateName={async () => handleUpdateName(player, i)}
))} onUpdateOwner={async () => handleUpdateOwner(player, i)}
</div> onUpdateTexture={() => {
)} setTextureUpdating(i);
<div className="card-footer"> }}
<div className="float-right"> onDelete={async () => handleDelete(player)}
<Pagination page={page} totalPages={totalPages} onChange={setPage} /> />
</div> ))}
</div> </div>
<ModalUpdateTexture )}
open={textureUpdating > -1} <div className='card-footer'>
onSubmit={handleUpdateTexture} <div className='float-right'>
onClose={handleCloseModalUpdateTexture} <Pagination page={page} totalPages={totalPages} onChange={setPage}/>
/> </div>
</div> </div>
) <ModalUpdateTexture
open={textureUpdating > -1}
onSubmit={handleUpdateTexture}
onClose={handleCloseModalUpdateTexture}
/>
</div>
);
} }
export default hot(PlayersManagement) export default PlayersManagement;

View File

@ -1,5 +1,5 @@
import styled from '@emotion/styled' import * as breakpoints from '@/styles/breakpoints';
import * as breakpoints from '@/styles/breakpoints' import styled from '@emotion/styled';
export const Box = styled.div` export const Box = styled.div`
width: 48%; width: 48%;
@ -8,4 +8,4 @@ export const Box = styled.div`
${breakpoints.lessThan(breakpoints.Breakpoint.lg)} { ${breakpoints.lessThan(breakpoints.Breakpoint.lg)} {
width: 98%; width: 98%;
} }
` `;

View File

@ -1,8 +1,8 @@
import React from 'react'
import styled from '@emotion/styled' import type {Plugin} from './types';
import { t } from '@/scripts/i18n' import {t} from '@/scripts/i18n';
import type { Plugin } from './types' import styled from '@emotion/styled';
import clsx from 'clsx' import clsx from 'clsx';
const Box = styled.div` const Box = styled.div`
cursor: default; cursor: default;
@ -15,7 +15,7 @@ const Box = styled.div`
.info-box-content { .info-box-content {
max-width: calc(100% - 70px); max-width: calc(100% - 70px);
} }
` `;
const ActionButton = styled.a` const ActionButton = styled.a`
transition-property: color; transition-property: color;
transition-duration: 0.3s; transition-duration: 0.3s;
@ -29,99 +29,102 @@ const ActionButton = styled.a`
&:not(:last-child) { &:not(:last-child) {
margin-right: 9px; margin-right: 9px;
} }
` `;
const Header = styled.div` const Header = styled.div`
max-width: calc(100% - 40px); max-width: calc(100% - 40px);
display: flex; display: flex;
align-items: center; align-items: center;
` `;
const Description = styled.div` const Description = styled.div`
font-size: 14px; font-size: 14px;
` `;
interface Props { type Props = {
plugin: Plugin readonly plugin: Plugin;
onEnable(plugin: Plugin): void onEnable: (plugin: Plugin) => void;
onDisable(plugin: Plugin): void onDisable: (plugin: Plugin) => void;
onDelete(plugin: Plugin): void onDelete: (plugin: Plugin) => void;
baseUrl: string readonly baseUrl: string;
} };
const InfoBox: React.FC<Props> = (props) => { const InfoBox: React.FC<Props> = props => {
const { plugin } = props const {plugin} = props;
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault() event.preventDefault();
if (event.target.checked) { if (event.target.checked) {
props.onEnable(plugin) props.onEnable(plugin);
} else { } else {
props.onDisable(plugin) props.onDisable(plugin);
} }
} };
const handleDelete = () => props.onDelete(plugin) const handleDelete = () => {
props.onDelete(plugin);
};
const isDarkMode = document.body.classList.contains('dark-mode') const isDarkMode = document.body.classList.contains('dark-mode');
return ( return (
<Box className={clsx('info-box', 'mr-3', { 'bg-gray-dark': isDarkMode })}> <Box className={clsx('info-box', 'mr-3', {'bg-gray-dark': isDarkMode})}>
<span className={`info-box-icon bg-${plugin.icon.bg}`}> <span className={`info-box-icon bg-${plugin.icon.bg}`}>
<i className={`${plugin.icon.faType} fa-${plugin.icon.fa}`} /> <i className={`${plugin.icon.faType} fa-${plugin.icon.fa}`}/>
</span> </span>
<div className="info-box-content"> <div className='info-box-content'>
<div className="d-flex justify-content-between"> <div className='d-flex justify-content-between'>
<Header> <Header>
<input <input
className="mr-2 d-inline-block" className='mr-2 d-inline-block'
type="checkbox" type='checkbox'
checked={plugin.enabled} checked={plugin.enabled}
title={ title={
plugin.enabled plugin.enabled
? t('admin.disablePlugin') ? t('admin.disablePlugin')
: t('admin.enablePlugin') : t('admin.enablePlugin')
} }
onChange={handleChange} onChange={handleChange}
/> />
<strong className="d-inline-block mr-2 text-truncate"> <strong className='d-inline-block mr-2 text-truncate'>
{plugin.title} {plugin.title}
</strong> </strong>
<span className="d-none d-sm-inline-block text-gray"> <span className='d-none d-sm-inline-block text-gray'>
v{plugin.version} v
</span> {plugin.version}
</Header> </span>
<div> </Header>
{plugin.readme && ( <div>
<ActionButton {plugin.readme && (
href={`${props.baseUrl}/admin/plugins/readme/${plugin.name}`} <ActionButton
title={t('admin.pluginReadme')} href={`${props.baseUrl}/admin/plugins/readme/${plugin.name}`}
> title={t('admin.pluginReadme')}
<i className="fas fa-question" /> >
</ActionButton> <i className='fas fa-question'/>
)} </ActionButton>
{plugin.enabled && plugin.config && ( )}
<ActionButton {plugin.enabled && plugin.config && (
href={`${props.baseUrl}/admin/plugins/config/${plugin.name}`} <ActionButton
title={t('admin.configurePlugin')} href={`${props.baseUrl}/admin/plugins/config/${plugin.name}`}
> title={t('admin.configurePlugin')}
<i className="fas fa-cog" /> >
</ActionButton> <i className='fas fa-cog'/>
)} </ActionButton>
<ActionButton )}
href="#" <ActionButton
title={t('admin.deletePlugin')} href='#'
onClick={handleDelete} title={t('admin.deletePlugin')}
> onClick={handleDelete}
<i className="fas fa-trash" /> >
</ActionButton> <i className='fas fa-trash'/>
</div> </ActionButton>
</div> </div>
<Description className="mt-2 text-truncate" title={plugin.description}> </div>
{plugin.description} <Description className='mt-2 text-truncate' title={plugin.description}>
</Description> {plugin.description}
</div> </Description>
</Box> </div>
) </Box>
} );
};
export default InfoBox export default InfoBox;

View File

@ -1,247 +1,244 @@
import React, { useState, useEffect } from 'react' import type {Plugin} from './types';
import { hot } from 'react-hot-loader/root' import FileInput from '@/components/FileInput';
import { useImmer } from 'use-immer' import Loading from '@/components/Loading';
import { t } from '@/scripts/i18n' import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net' import * as fetch from '@/scripts/net';
import { toast, showModal } from '@/scripts/notify' import {showModal, toast} from '@/scripts/notify';
import FileInput from '@/components/FileInput' import {useEffect, useState} from 'react';
import Loading from '@/components/Loading' import {useImmer} from 'use-immer';
import InfoBox from './InfoBox' import InfoBox from './InfoBox';
import type { Plugin } from './types'
const PluginsManagement: React.FC = () => { function PluginsManagement() {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true);
const [plugins, setPlugins] = useImmer<Plugin[]>([]) const [plugins, setPlugins] = useImmer<Plugin[]>([]);
const [file, setFile] = useState<File | null>(null) const [file, setFile] = useState<File | undefined>(null);
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false);
const [url, setUrl] = useState('') const [url, setUrl] = useState('');
const [isDownloading, setIsDownloading] = useState(false) const [isDownloading, setIsDownloading] = useState(false);
useEffect(() => { useEffect(() => {
const getPlugins = async () => { const getPlugins = async () => {
setIsLoading(true) setIsLoading(true);
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data') const plugins = await fetch.get<Plugin[]>('/admin/plugins/data');
setPlugins(() => plugins) setPlugins(() => plugins);
setIsLoading(false) setIsLoading(false);
} };
getPlugins()
}, [])
const handleEnable = async (plugin: Plugin, i: number) => { getPlugins();
const { }, []);
code,
message,
data: { reason } = { reason: [] },
} = await fetch.post<
fetch.ResponseBody<{
reason: string[]
}>
>('/admin/plugins/manage', {
action: 'enable',
name: plugin.name,
})
if (code === 0) {
toast.success(message)
setPlugins((plugins) => {
plugins[i]!.enabled = true
})
} else {
showModal({
mode: 'alert',
children: (
<div>
<p>{message}</p>
<ul>
{reason.map((t, i) => (
<li key={i}>{t}</li>
))}
</ul>
</div>
),
})
}
}
const handleDisable = async (plugin: Plugin, i: number) => { const handleEnable = async (plugin: Plugin, i: number) => {
const { code, message } = await fetch.post<fetch.ResponseBody>( const {
'/admin/plugins/manage', code,
{ message,
action: 'disable', data: {reason} = {reason: []},
name: plugin.name, } = await fetch.post<
}, fetch.ResponseBody<{
) reason: string[];
if (code === 0) { }>
toast.success(message) >('/admin/plugins/manage', {
setPlugins((plugins) => { action: 'enable',
plugins[i]!.enabled = false name: plugin.name,
}) });
} else { if (code === 0) {
toast.error(message) toast.success(message);
} setPlugins(plugins => {
} plugins[i].enabled = true;
});
} else {
showModal({
mode: 'alert',
children: (
<div>
<p>{message}</p>
<ul>
{reason.map((t, i) =>
<li key={i}>{t}</li>)}
</ul>
</div>
),
});
}
};
const handleDelete = async (plugin: Plugin) => { const handleDisable = async (plugin: Plugin, i: number) => {
try { const {code, message} = await fetch.post<fetch.ResponseBody>(
await showModal({ '/admin/plugins/manage',
title: plugin.title, {
text: t('admin.confirmDeletion'), action: 'disable',
okButtonType: 'danger', name: plugin.name,
}) },
} catch { );
return if (code === 0) {
} toast.success(message);
setPlugins(plugins => {
plugins[i].enabled = false;
});
} else {
toast.error(message);
}
};
const { code, message } = await fetch.post<fetch.ResponseBody>( const handleDelete = async (plugin: Plugin) => {
'/admin/plugins/manage', try {
{ await showModal({
action: 'delete', title: plugin.title,
name: plugin.name, text: t('admin.confirmDeletion'),
}, okButtonType: 'danger',
) });
if (code === 0) { } catch {
const { name } = plugin return;
setPlugins((plugins) => plugins.filter((plugin) => plugin.name !== name)) }
toast.success(message)
} else {
toast.error(message)
}
}
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const {code, message} = await fetch.post<fetch.ResponseBody>(
setFile(event.target.files![0]!) '/admin/plugins/manage',
} {
action: 'delete',
name: plugin.name,
},
);
if (code === 0) {
const {name} = plugin;
setPlugins(plugins => plugins.filter(plugin => plugin.name !== name));
toast.success(message);
} else {
toast.error(message);
}
};
const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setUrl(event.target.value) setFile(event.target.files![0]);
} };
const handleUpload = async () => { const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!file) { setUrl(event.target.value);
return };
}
setIsUploading(true) const handleUpload = async () => {
const formData = new FormData() if (!file) {
formData.append('file', file, file.name) return;
const { code, message } = await fetch.post<fetch.ResponseBody>( }
'/admin/plugins/upload',
formData,
)
setIsUploading(false) setIsUploading(true);
if (code === 0) { const formData = new FormData();
toast.success(message) formData.append('file', file, file.name);
setFile(null) const {code, message} = await fetch.post<fetch.ResponseBody>(
'/admin/plugins/upload',
formData,
);
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data') setIsUploading(false);
setPlugins(() => plugins) if (code === 0) {
} else { toast.success(message);
toast.error(message) setFile(null);
}
}
const handleSubmitUrl = async () => { const plugins = await fetch.get<Plugin[]>('/admin/plugins/data');
setIsDownloading(true) setPlugins(() => plugins);
const { code, message } = await fetch.post<fetch.ResponseBody>( } else {
'/admin/plugins/wget', toast.error(message);
{ url }, }
) };
setIsDownloading(false) const handleSubmitUrl = async () => {
if (code === 0) { setIsDownloading(true);
toast.success(message) const {code, message} = await fetch.post<fetch.ResponseBody>(
setUrl('') '/admin/plugins/wget',
{url},
);
const plugins = await fetch.get<Plugin[]>('/admin/plugins/data') setIsDownloading(false);
setPlugins(() => plugins) if (code === 0) {
} else { toast.success(message);
toast.error(message) setUrl('');
}
}
const chunks = Array(Math.ceil(plugins.length / 2)) const plugins = await fetch.get<Plugin[]>('/admin/plugins/data');
.fill(null) setPlugins(() => plugins);
.map((_, i) => plugins.slice(i * 2, (i + 1) * 2) as [Plugin, Plugin?]) } else {
toast.error(message);
}
};
return ( const chunks = Array.from({length: Math.ceil(plugins.length / 2)})
<div className="row"> .fill(null)
<div className="col-lg-8"> .map((_, i) => plugins.slice(i * 2, (i + 1) * 2) as [Plugin, Plugin?]);
{isLoading ? (
<Loading /> return (
) : plugins.length === 0 ? ( <div className='row'>
t('general.noResult') <div className='col-lg-8'>
) : ( {isLoading
chunks.map((chunk, i) => ( ? <Loading/>
<div className="row" key={`${chunk[0].name}&${chunk[1]?.name}`}> : plugins.length === 0
{(chunk as Plugin[]).map((plugin, j) => ( ? t('general.noResult')
<div className="col-md-6" key={plugin.name}> : chunks.map((chunk, i) => (
<InfoBox <div key={`${chunk[0].name}&${chunk[1]?.name}`} className='row'>
plugin={plugin} {(chunk as Plugin[]).map((plugin, index) => (
onEnable={(plugin) => handleEnable(plugin, i * 2 + j)} <div key={plugin.name} className='col-md-6'>
onDisable={(plugin) => handleDisable(plugin, i * 2 + j)} <InfoBox
onDelete={handleDelete} plugin={plugin}
baseUrl={blessing.base_url} baseUrl={blessing.base_url}
/> onEnable={async plugin => handleEnable(plugin, i * 2 + index)}
</div> onDisable={async plugin => handleDisable(plugin, i * 2 + index)}
))} onDelete={handleDelete}
</div> />
)) </div>
)} ))}
</div> </div>
<div className="col-lg-4"> ))}
<div className="card card-primary card-outline"> </div>
<div className="card-header"> <div className='col-lg-4'>
<h3 className="card-title">{t('admin.uploadArchive')}</h3> <div className='card card-primary card-outline'>
</div> <div className='card-header'>
<div className="card-body"> <h3 className='card-title'>{t('admin.uploadArchive')}</h3>
<p>{t('admin.uploadArchiveNotice')}</p> </div>
<FileInput <div className='card-body'>
file={file} <p>{t('admin.uploadArchiveNotice')}</p>
accept="application/zip" <FileInput
onChange={handleFileChange} file={file}
/> accept='application/zip'
</div> onChange={handleFileChange}
<div className="card-footer"> />
<button </div>
className="btn btn-primary float-right" <div className='card-footer'>
disabled={isUploading} <button
onClick={handleUpload} className='btn btn-primary float-right'
> disabled={isUploading}
{isUploading ? <Loading /> : t('general.submit')} onClick={handleUpload}
</button> >
</div> {isUploading ? <Loading/> : t('general.submit')}
</div> </button>
<div className="card card-primary card-outline"> </div>
<div className="card-header"> </div>
<h3 className="card-title">{t('admin.downloadRemote')}</h3> <div className='card card-primary card-outline'>
</div> <div className='card-header'>
<div className="card-body"> <h3 className='card-title'>{t('admin.downloadRemote')}</h3>
<p>{t('admin.downloadRemoteNotice')}</p> </div>
<div className="form-group"> <div className='card-body'>
<label htmlFor="zip-url">URL</label> <p>{t('admin.downloadRemoteNotice')}</p>
<input <div className='form-group'>
type="text" <label htmlFor='zip-url'>URL</label>
id="zip-url" <input
className="form-control" type='text'
inputMode="url" id='zip-url'
value={url} className='form-control'
onChange={handleUrlChange} inputMode='url'
/> value={url}
</div> onChange={handleUrlChange}
</div> />
<div className="card-footer"> </div>
<button </div>
className="btn btn-primary float-right" <div className='card-footer'>
disabled={isDownloading} <button
onClick={handleSubmitUrl} className='btn btn-primary float-right'
> disabled={isDownloading}
{isDownloading ? <Loading /> : t('general.submit')} onClick={handleSubmitUrl}
</button> >
</div> {isDownloading ? <Loading/> : t('general.submit')}
</div> </button>
</div> </div>
</div> </div>
) </div>
</div>
);
} }
export default hot(PluginsManagement) export default PluginsManagement;

View File

@ -1,10 +1,10 @@
export type Plugin = { export type Plugin = {
name: string name: string;
title: string title: string;
description: string description: string;
version: string version: string;
enabled: boolean enabled: boolean;
config: boolean config: boolean;
readme: boolean readme: boolean;
icon: { fa: string; faType: 'fas' | 'fab'; bg: string } icon: {fa: string; faType: 'fas' | 'fab'; bg: string};
} };

View File

@ -1,92 +1,100 @@
import React from 'react'
import { t } from '@/scripts/i18n'
import type { Plugin } from './types'
interface Props { import type {Plugin} from './types';
plugin: Plugin import {t} from '@/scripts/i18n';
isInstalling: boolean
onInstall(): void
onUpdate(): void
}
const Row: React.FC<Props> = (props) => { type Props = {
const { plugin, isInstalling } = props readonly plugin: Plugin;
readonly isInstalling: boolean;
onInstall: () => void;
onUpdate: () => void;
};
const allDeps = Object.entries(plugin.dependencies.all) const Row: React.FC<Props> = props => {
const unsatisfied = Object.keys(plugin.dependencies.unsatisfied) const {plugin, isInstalling} = props;
return ( const allDeps = Object.entries(plugin.dependencies.all);
<tr> const unsatisfied = Object.keys(plugin.dependencies.unsatisfied);
<td style={{ width: '18%' }}>
<div>
<b>{plugin.title}</b>
</div>
<div>{plugin.name}</div>
</td>
<td style={{ width: '37%' }}>{plugin.description}</td>
<td>{plugin.author}</td>
<td>{plugin.version}</td>
<td style={{ width: '100px' }}>
{allDeps.length === 0 ? (
<i>{t('admin.noDependencies')}</i>
) : (
<div className="d-flex flex-column">
{allDeps.map(([name, constraint]) => {
const classes = [
'mb-1',
'badge',
`bg-${unsatisfied.includes(name) ? 'red' : 'green'}`,
]
return (
<span key={name} className={classes.join(' ')}>
{name}: {constraint}
</span>
)
})}
</div>
)}
</td>
<td style={{ width: '12%' }}>
{plugin.can_update ? (
<button
className="btn btn-success"
disabled={isInstalling}
onClick={props.onUpdate}
>
{isInstalling ? (
<>
<i className="fas fa-spinner fa-spin mr-1"></i>
{t('admin.pluginUpdating')}
</>
) : (
<>
<i className="fas fa-sync-alt mr-1"></i>
{t('admin.updatePlugin')}
</>
)}
</button>
) : (
<button
className="btn btn-default"
disabled={props.isInstalling || !!plugin.installed}
onClick={props.onInstall}
>
{isInstalling ? (
<>
<i className="fas fa-spinner fa-spin mr-1"></i>
{t('admin.pluginInstalling')}
</>
) : (
<>
<i className="fas fa-download mr-1"></i>
{t('admin.installPlugin')}
</>
)}
</button>
)}
</td>
</tr>
)
}
export default Row return (
<tr>
<td style={{width: '18%'}}>
<div>
<b>{plugin.title}</b>
</div>
<div>{plugin.name}</div>
</td>
<td style={{width: '37%'}}>{plugin.description}</td>
<td>{plugin.author}</td>
<td>{plugin.version}</td>
<td style={{width: '100px'}}>
{allDeps.length === 0
? <i>{t('admin.noDependencies')}</i>
: (
<div className='d-flex flex-column'>
{allDeps.map(([name, constraint]) => {
const classes = [
'mb-1',
'badge',
`bg-${unsatisfied.includes(name) ? 'red' : 'green'}`,
];
return (
<span key={name} className={classes.join(' ')}>
{name}
:
{constraint}
</span>
);
})}
</div>
)}
</td>
<td style={{width: '12%'}}>
{plugin.can_update
? (
<button
className='btn btn-success'
disabled={isInstalling}
onClick={props.onUpdate}
>
{isInstalling
? (
<>
<i className='fas fa-spinner fa-spin mr-1'/>
{t('admin.pluginUpdating')}
</>
)
: (
<>
<i className='fas fa-sync-alt mr-1'/>
{t('admin.updatePlugin')}
</>
)}
</button>
)
: (
<button
className='btn btn-default'
disabled={props.isInstalling || Boolean(plugin.installed)}
onClick={props.onInstall}
>
{isInstalling
? (
<>
<i className='fas fa-spinner fa-spin mr-1'/>
{t('admin.pluginInstalling')}
</>
)
: (
<>
<i className='fas fa-download mr-1'/>
{t('admin.installPlugin')}
</>
)}
</button>
)}
</td>
</tr>
);
};
export default Row;

View File

@ -1,167 +1,163 @@
import React, { useState, useEffect, useMemo } from 'react' import type {Plugin} from './types';
import { hot } from 'react-hot-loader/root' import Loading from '@/components/Loading';
import { enableMapSet } from 'immer' import Pagination from '@/components/Pagination';
import { useImmer } from 'use-immer' import {t} from '@/scripts/i18n';
import { t } from '@/scripts/i18n' import * as fetch from '@/scripts/net';
import * as fetch from '@/scripts/net' import {showModal, toast} from '@/scripts/notify';
import { toast, showModal } from '@/scripts/notify' import {enableMapSet} from 'immer';
import Loading from '@/components/Loading' import {useEffect, useMemo, useState} from 'react';
import Pagination from '@/components/Pagination' import {useImmer} from 'use-immer';
import type { Plugin } from './types' import Row from './Row';
import Row from './Row'
enableMapSet() enableMapSet();
const PluginsMarket: React.FC = () => { export default function PluginsMarket() {
const [plugins, setPlugins] = useImmer<Plugin[]>([]) const [plugins, setPlugins] = useImmer<Plugin[]>([]);
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('') const [search, setSearch] = useState('');
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1) const [totalPages, setTotalPages] = useState(1);
const [installings, setInstallings] = useImmer<Set<string>>(() => new Set()) const [installings, setInstallings] = useImmer<Set<string>>(() => new Set());
const searchedPlugins = useMemo( const searchedPlugins = useMemo(
() => () =>
plugins.filter( plugins.filter(plugin =>
(plugin) => plugin.name.includes(search) || plugin.title.includes(search)),
plugin.name.includes(search) || plugin.title.includes(search), [plugins, search],
), );
[plugins, search],
)
useEffect(() => { useEffect(() => {
const getPlugins = async () => { const getPlugins = async () => {
setIsLoading(true) setIsLoading(true);
const plugins = await fetch.get<Plugin[]>('/admin/plugins/market/list') const plugins = await fetch.get<Plugin[]>('/admin/plugins/market/list');
setPlugins(() => plugins) setPlugins(() => plugins);
setTotalPages(Math.ceil(plugins.length / 10)) setTotalPages(Math.ceil(plugins.length / 10));
setIsLoading(false) setIsLoading(false);
} };
getPlugins()
}, [])
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => { void getPlugins();
const search = event.target.value }, []);
setSearch(search)
setPage(1)
const searchedPlugins = plugins.filter( const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
(plugin) => plugin.name.includes(search) || plugin.title.includes(search), const search = event.target.value;
) setSearch(search);
setTotalPages(Math.ceil(searchedPlugins.length / 10)) setPage(1);
}
const handleInstall = async (plugin: Plugin, index: number) => { const searchedPlugins = plugins.filter(plugin => plugin.name.includes(search) || plugin.title.includes(search));
setInstallings((installings) => { setTotalPages(Math.ceil(searchedPlugins.length / 10));
installings.add(plugin.name) };
})
const { const handleInstall = async (plugin: Plugin, index: number) => {
code, setInstallings(installings => {
message, installings.add(plugin.name);
data = { reason: [] }, });
} = await fetch.post<fetch.ResponseBody<{ reason: string[] }>>(
'/admin/plugins/market/download',
{
name: plugin.name,
},
)
if (code === 0) {
toast.success(message)
setPlugins((plugins) => {
plugins[index]!.can_update = false
plugins[index]!.installed = plugins[index]!.version
})
} else {
showModal({
mode: 'alert',
children: (
<div>
<p>{message}</p>
<ul>
{data.reason.map((t, i) => (
<li key={i}>{t}</li>
))}
</ul>
</div>
),
})
}
setInstallings((installings) => { const {
installings.delete(plugin.name) code,
}) message,
} data = {reason: []},
} = await fetch.post<fetch.ResponseBody<{reason: string[]}>>(
'/admin/plugins/market/download',
{
name: plugin.name,
},
);
if (code === 0) {
toast.success(message);
setPlugins(plugins => {
plugins[index].can_update = false;
plugins[index].installed = plugins[index].version;
});
} else {
void showModal({
mode: 'alert',
children: (
<div>
<p>{message}</p>
<ul>
{data.reason.map((t, i) =>
<li key={i}>{t}</li>)}
</ul>
</div>
),
});
}
const handleUpdate = async (plugin: Plugin, index: number) => { setInstallings(installings => {
try { installings.delete(plugin.name);
await showModal({ });
text: t('admin.confirmUpdate', { };
plugin: plugin.title,
old: plugin.installed,
new: plugin.version,
}),
})
} catch {
return
}
handleInstall(plugin, index) const handleUpdate = async (plugin: Plugin, index: number) => {
} try {
await showModal({
text: t('admin.confirmUpdate', {
plugin: plugin.title,
old: plugin.installed.toString(),
new: plugin.version,
}),
});
} catch {
return;
}
const pagedPlugins = searchedPlugins.slice((page - 1) * 10, page * 10) void handleInstall(plugin, index);
};
return ( const pagedPlugins = searchedPlugins.slice((page - 1) * 10, page * 10);
<div className="card">
<div className="card-header"> return (
<input <div className='card'>
type="text" <div className='card-header'>
className="form-control" <input
placeholder={t('vendor.datatable.search')} type='text'
value={search} className='form-control'
onChange={handleSearchChange} placeholder={t('vendor.datatable.search')}
/> value={search}
</div> onChange={handleSearchChange}
{isLoading ? ( />
<div className="card-body"> </div>
<Loading /> {isLoading
</div> ? (
) : searchedPlugins.length === 0 ? ( <div className='card-body'>
<div className="card-body text-center">{t('general.noResult')}</div> <Loading/>
) : ( </div>
<div className="card-body table-responsive p-0"> )
<table className="table table-striped"> : searchedPlugins.length === 0
<thead> ? <div className='card-body text-center'>{t('general.noResult')}</div>
<tr> : (
<th>{t('admin.pluginTitle')}</th> <div className='card-body table-responsive p-0'>
<th>{t('admin.pluginDescription')}</th> <table className='table table-striped'>
<th>{t('admin.pluginAuthor')}</th> <thead>
<th>{t('admin.pluginVersion')}</th> <tr>
<th>{t('admin.pluginDependencies')}</th> <th>{t('admin.pluginTitle')}</th>
<th>{t('admin.operationsTitle')}</th> <th>{t('admin.pluginDescription')}</th>
</tr> <th>{t('admin.pluginAuthor')}</th>
</thead> <th>{t('admin.pluginVersion')}</th>
<tbody> <th>{t('admin.pluginDependencies')}</th>
{pagedPlugins.map((plugin, i) => ( <th>{t('admin.operationsTitle')}</th>
<Row </tr>
key={plugin.name} </thead>
plugin={plugin} <tbody>
isInstalling={installings.has(plugin.name)} {pagedPlugins.map((plugin, i) => (
onInstall={() => handleInstall(plugin, (page - 1) * 10 + i)} <Row
onUpdate={() => handleUpdate(plugin, (page - 1) * 10 + i)} key={plugin.name}
/> plugin={plugin}
))} isInstalling={installings.has(plugin.name)}
</tbody> onInstall={async () => handleInstall(plugin, ((page - 1) * 10) + i)}
</table> onUpdate={async () => handleUpdate(plugin, ((page - 1) * 10) + i)}
</div> />
)} ))}
<div className="card-footer"> </tbody>
<div className="float-right"> </table>
<Pagination page={page} totalPages={totalPages} onChange={setPage} /> </div>
</div> )}
</div> <div className='card-footer'>
</div> <div className='float-right'>
) <Pagination page={page} totalPages={totalPages} onChange={setPage}/>
</div>
</div>
</div>
);
} }
export default hot(PluginsMarket)

View File

@ -1,13 +1,13 @@
export type Plugin = { export type Plugin = {
name: string name: string;
version: string version: string;
title: string title: string;
description: string description: string;
author: string author: string;
installed: string | false installed: string | false;
can_update?: boolean can_update?: boolean;
dependencies: { dependencies: {
all: Record<string, string> all: Record<string, string>;
unsatisfied: Record<string, string> unsatisfied: Record<string, string>;
} };
} };

View File

@ -1,8 +1,8 @@
import React from 'react'
import styled from '@emotion/styled' import type {Texture} from '@/scripts/types';
import { t } from '@/scripts/i18n' import {t} from '@/scripts/i18n';
import type { Texture } from '@/scripts/types' import styled from '@emotion/styled';
import { Report, Status } from './types' import {type Report, Status} from './types';
const Card = styled.div` const Card = styled.div`
width: 240px; width: 240px;
@ -27,121 +27,131 @@ const Card = styled.div`
margin: 2.5px 0; margin: 2.5px 0;
} }
} }
` `;
interface Props { type Props = {
report: Report readonly report: Report;
onClick(texture: Texture | null): void onClick: (texture: Texture | undefined) => void;
onBan(): void onBan: () => void;
onDelete(): void onDelete: () => void;
onReject(): void onReject: () => void;
} };
const ImageBox: React.FC<Props> = (props) => { const ImageBox: React.FC<Props> = props => {
const { report } = props const {report} = props;
const preview = `${blessing.base_url}/preview/${report.tid}?height=150` const preview = `${blessing.base_url}/preview/${report.tid}?height=150`;
const previewPNG = `${preview}&png` const previewPNG = `${preview}&png`;
const handleImageClick = () => props.onClick(report.texture) const handleImageClick = () => {
props.onClick(report.texture);
};
return ( return (
<Card className="card mr-3 mb-3"> <Card className='card mr-3 mb-3'>
<div className="card-header"> <div className='card-header'>
<b> <b>
{t('skinlib.show.uploader')} {t('skinlib.show.uploader')}
{': '} {': '}
</b> </b>
<span className="mr-1">{report.texture_uploader?.nickname}</span> <span className='mr-1'>{report.texture_uploader?.nickname}</span>
(UID: {report.uploader}) (UID:
</div> {' '}
<div className="card-body"> {report.uploader}
<picture> )
<source srcSet={preview} type="image/webp" /> </div>
<img <div className='card-body'>
src={previewPNG} <picture>
alt={report.tid.toString()} <source srcSet={preview} type='image/webp'/>
className="card-img-top" <img
onClick={handleImageClick} src={previewPNG}
/> alt={report.tid.toString()}
</picture> className='card-img-top'
</div> onClick={handleImageClick}
<div className="card-footer"> />
<div className="d-flex justify-content-between"> </picture>
<div> </div>
{report.status === Status.Pending ? ( <div className='card-footer'>
<span className="badge bg-warning">{t('report.status.0')}</span> <div className='d-flex justify-content-between'>
) : report.status === Status.Resolved ? ( <div>
<span className="badge bg-success">{t('report.status.1')}</span> {report.status === Status.Pending
) : ( ? <span className='badge bg-warning'>{t('report.status.0')}</span>
<span className="badge bg-danger">{t('report.status.2')}</span> : report.status === Status.Resolved
)} ? <span className='badge bg-success'>{t('report.status.1')}</span>
<span className="badge bg-info ml-1">TID: {report.tid}</span> : <span className='badge bg-danger'>{t('report.status.2')}</span>}
</div> <span className='badge bg-info ml-1'>
<div className="dropdown"> TID:
<a {report.tid}
className="text-gray" </span>
href="#" </div>
data-toggle="dropdown" <div className='dropdown'>
aria-expanded="false" <a
> className='text-gray'
<i className="fas fa-cog"></i> href='#'
</a> data-toggle='dropdown'
<div className="dropdown-menu dropdown-menu-right"> aria-expanded='false'
<a >
href={`${blessing.base_url}/skinlib/show/${report.tid}`} <i className='fas fa-cog'/>
className="dropdown-item" </a>
target="_blank" <div className='dropdown-menu dropdown-menu-right'>
> <a
<i className="fas fa-share-square mr-2"></i> href={`${blessing.base_url}/skinlib/show/${report.tid}`}
{t('user.viewInSkinlib')} className='dropdown-item'
</a> target='_blank'
<a href="#" className="dropdown-item" onClick={props.onBan}> rel='noreferrer'
<i className="fas fa-user-slash mr-2"></i> >
{t('report.ban')} <i className='fas fa-share-square mr-2'/>
</a> {t('user.viewInSkinlib')}
<a </a>
href="#" <a href='#' className='dropdown-item' onClick={props.onBan}>
className="dropdown-item dropdown-item-danger" <i className='fas fa-user-slash mr-2'/>
onClick={props.onDelete} {t('report.ban')}
> </a>
<i className="fas fa-trash mr-2"></i> <a
{t('skinlib.show.delete-texture')} href='#'
</a> className='dropdown-item dropdown-item-danger'
<a href="#" className="dropdown-item" onClick={props.onReject}> onClick={props.onDelete}
<i className="fas fa-thumbs-down mr-2"></i> >
{t('report.reject')} <i className='fas fa-trash mr-2'/>
</a> {t('skinlib.show.delete-texture')}
</div> </a>
</div> <a href='#' className='dropdown-item' onClick={props.onReject}>
</div> <i className='fas fa-thumbs-down mr-2'/>
<div> {t('report.reject')}
<b> </a>
{t('report.reporter')} </div>
{': '} </div>
</b> </div>
<span className="mr-1">{report.informer?.nickname}</span> <div>
(UID: {report.reporter}) <b>
</div> {t('report.reporter')}
<details> {': '}
<summary className="text-truncate"> </b>
<b> <span className='mr-1'>{report.informer?.nickname}</span>
{t('report.reason')} (UID:
{': '} {' '}
</b> {report.reporter}
{report.reason} )
</summary> </div>
<div>{report.reason}</div> <details>
<div> <summary className='text-truncate'>
<small> <b>
{t('report.time')} {t('report.reason')}
{': '} {': '}
{report.report_at} </b>
</small> {report.reason}
</div> </summary>
</details> <div>{report.reason}</div>
</div> <div>
</Card> <small>
) {t('report.time')}
} {': '}
{report.report_at}
</small>
</div>
</details>
</div>
</Card>
);
};
export default ImageBox export default ImageBox;

View File

@ -1,155 +1,156 @@
import React, { useState, useEffect } from 'react' import type {Report, Status} from './types';
import { hot } from 'react-hot-loader/root' import Loading from '@/components/Loading';
import { useImmer } from 'use-immer' import Pagination from '@/components/Pagination';
import { t } from '@/scripts/i18n' import ViewerSkeleton from '@/components/ViewerSkeleton';
import * as fetch from '@/scripts/net' import {t} from '@/scripts/i18n';
import { Paginator, Texture, TextureType } from '@/scripts/types' import * as fetch from '@/scripts/net';
import { toast, showModal } from '@/scripts/notify' import {showModal, toast} from '@/scripts/notify';
import Loading from '@/components/Loading' import {type Paginator, type Texture, TextureType} from '@/scripts/types';
import Pagination from '@/components/Pagination' import React, {useEffect, useState} from 'react';
import ViewerSkeleton from '@/components/ViewerSkeleton' import {useImmer} from 'use-immer';
import type { Report, Status } from './types' import ImageBox from './ImageBox';
import ImageBox from './ImageBox'
const Previewer = React.lazy(() => import('@/components/Viewer')) const Previewer = React.lazy(async () => import('@/components/Viewer'));
const ReportsManagement: React.FC = () => { function ReportsManagement() {
const [reports, setReports] = useImmer<Report[]>([]) const [reports, setReports] = useImmer<Report[]>([]);
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1) const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true);
const [query, setQuery] = useState('status:0 sort:-report_at') const [query, setQuery] = useState('status:0 sort:-report_at');
const [viewingTexture, setViewingTexture] = useState<Texture | null>(null) const [viewingTexture, setViewingTexture] = useState<Texture | undefined>(null);
const getReports = async () => { const getReports = async () => {
setIsLoading(true) setIsLoading(true);
const { data, last_page }: Paginator<Report> = await fetch.get( const {data, last_page}: Paginator<Report> = await fetch.get(
'/admin/reports/list', '/admin/reports/list',
{ {
q: query, q: query,
page, page: page.toString(),
}, },
) );
setTotalPages(last_page) setTotalPages(last_page);
setReports(() => data) setReports(() => data);
setIsLoading(false) setIsLoading(false);
} };
useEffect(() => { useEffect(() => {
getReports() getReports();
}, [page]) }, [page]);
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value) setQuery(event.target.value);
} };
const handleSubmitQuery = (event: React.FormEvent) => { const handleSubmitQuery = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault();
getReports() getReports();
} };
const handleProceedReport = async ( const handleProceedReport = async (
report: Report, report: Report,
index: number, index: number,
action: 'ban' | 'delete' | 'reject', action: 'ban' | 'delete' | 'reject',
) => { ) => {
type Ok = { code: 0; message: string; data: { status: Status } } type Ok = {code: 0; message: string; data: {status: Status}};
type Err = { code: 1; message: string } type Error_ = {code: 1; message: string};
const resp = await fetch.put<Ok | Err>(`/admin/reports/${report.id}`, { const resp = await fetch.put<Ok | Error_>(`/admin/reports/${report.id}`, {
action, action,
}) });
if (resp.code === 0) { if (resp.code === 0) {
toast.success(resp.message) toast.success(resp.message);
setReports((reports) => { setReports(reports => {
reports[index]!.status = resp.data.status reports[index].status = resp.data.status;
}) });
} else { } else {
toast.error(resp.message) toast.error(resp.message);
} }
} };
const handleDelete = async (report: Report, index: number) => { const handleDelete = async (report: Report, index: number) => {
try { try {
await showModal({ await showModal({
text: t('skinlib.deleteNotice'), text: t('skinlib.deleteNotice'),
okButtonType: 'danger', okButtonType: 'danger',
}) });
} catch { } catch {
return return;
} }
handleProceedReport(report, index, 'delete') handleProceedReport(report, index, 'delete');
} };
const textureUrl = const textureUrl
viewingTexture && `${blessing.base_url}/textures/${viewingTexture.hash}` = viewingTexture && `${blessing.base_url}/textures/${viewingTexture.hash}`;
return ( return (
<div className="row"> <div className='row'>
<div className="col-lg-8"> <div className='col-lg-8'>
<div className="card"> <div className='card'>
<div className="card-header"> <div className='card-header'>
<form className="input-group" onSubmit={handleSubmitQuery}> <form className='input-group' onSubmit={handleSubmitQuery}>
<input <input
type="text" type='text'
className="form-control" className='form-control'
title={t('vendor.datatable.search')} title={t('vendor.datatable.search')}
value={query} value={query}
onChange={handleQueryChange} onChange={handleQueryChange}
/> />
<div className="input-group-append"> <div className='input-group-append'>
<button className="btn btn-primary" type="submit"> <button className='btn btn-primary' type='submit'>
{t('vendor.datatable.search')} {t('vendor.datatable.search')}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
{isLoading ? ( {isLoading
<div className="card-body"> ? (
<Loading /> <div className='card-body'>
</div> <Loading/>
) : reports.length === 0 ? ( </div>
<div className="card-body text-center">{t('general.noResult')}</div> )
) : ( : reports.length === 0
<div className="card-body d-flex flex-wrap"> ? <div className='card-body text-center'>{t('general.noResult')}</div>
{reports.map((report, i) => ( : (
<ImageBox <div className='card-body d-flex flex-wrap'>
key={report.id} {reports.map((report, i) => (
report={report} <ImageBox
onClick={setViewingTexture} key={report.id}
onBan={() => handleProceedReport(report, i, 'ban')} report={report}
onDelete={() => handleDelete(report, i)} onClick={setViewingTexture}
onReject={() => handleProceedReport(report, i, 'reject')} onBan={async () => handleProceedReport(report, i, 'ban')}
/> onDelete={async () => handleDelete(report, i)}
))} onReject={async () => handleProceedReport(report, i, 'reject')}
</div> />
)} ))}
<div className="card-footer"> </div>
<div className="float-right"> )}
<Pagination <div className='card-footer'>
page={page} <div className='float-right'>
totalPages={totalPages} <Pagination
onChange={setPage} page={page}
/> totalPages={totalPages}
</div> onChange={setPage}
</div> />
</div> </div>
</div> </div>
<div className="col-lg-4"> </div>
<React.Suspense fallback={<ViewerSkeleton />}> </div>
<Previewer <div className='col-lg-4'>
{...{ <React.Suspense fallback={<ViewerSkeleton/>}>
[viewingTexture?.type === TextureType.Cape <Previewer
? TextureType.Cape {...{
: 'skin']: textureUrl, [viewingTexture?.type === TextureType.Cape
}} ? TextureType.Cape
isAlex={viewingTexture?.type === TextureType.Alex} : 'skin']: textureUrl,
/> }}
</React.Suspense> isAlex={viewingTexture?.type === TextureType.Alex}
</div> />
</div> </React.Suspense>
) </div>
</div>
);
} }
export default hot(ReportsManagement) export default ReportsManagement;

View File

@ -1,20 +1,20 @@
import type { Texture, User } from '@/scripts/types' import type {Texture, User} from '@/scripts/types';
export const enum Status { export const enum Status {
Pending = 0, Pending = 0,
Resolved = 1, Resolved = 1,
Rejected = 2, Rejected = 2,
} }
export type Report = { export type Report = {
id: number id: number;
tid: number tid: number;
texture: Texture | null texture: Texture | undefined;
uploader: number uploader: number;
texture_uploader: User | null texture_uploader: User | undefined;
reporter: number reporter: number;
informer: User | null informer: User | undefined;
reason: string reason: string;
status: Status status: Status;
report_at: string report_at: string;
} };

View File

@ -1,47 +1,50 @@
import styled from '@emotion/styled' import type {Line} from './types';
import React from 'react' import {t} from '@/scripts/i18n';
import { t } from '@/scripts/i18n' import styled from '@emotion/styled';
import type { Line } from './types'
const Group = styled.td` const Group = styled.td`
width: 15%; width: 15%;
` `;
const Key = styled.td` const Key = styled.td`
width: 20%; width: 20%;
` `;
const Operations = styled.td` const Operations = styled.td`
width: 25%; width: 25%;
` `;
interface Props { type Props = {
line: Line readonly line: Line;
onEdit(line: Line): void onEdit: (line: Line) => void;
onRemove(line: Line): void onRemove: (line: Line) => void;
} };
const Row: React.FC<Props> = (props) => { const Row: React.FC<Props> = props => {
const { line, onEdit, onRemove } = props const {line, onEdit, onRemove} = props;
const text = line.text[blessing.locale] const text = line.text[blessing.locale];
const handleEditClick = () => onEdit(line) const handleEditClick = () => {
onEdit(line);
};
const handleRemoveClick = () => onRemove(line) const handleRemoveClick = () => {
onRemove(line);
};
return ( return (
<tr> <tr>
<Group>{line.group}</Group> <Group>{line.group}</Group>
<Key>{line.key}</Key> <Key>{line.key}</Key>
<td>{text || t('admin.i18n.empty')}</td> <td>{text || t('admin.i18n.empty')}</td>
<Operations> <Operations>
<button className="btn btn-default mr-2" onClick={handleEditClick}> <button className='btn btn-default mr-2' onClick={handleEditClick}>
{t('admin.i18n.modify')} {t('admin.i18n.modify')}
</button> </button>
<button className="btn btn-danger" onClick={handleRemoveClick}> <button className='btn btn-danger' onClick={handleRemoveClick}>
{t('admin.i18n.delete')} {t('admin.i18n.delete')}
</button> </button>
</Operations> </Operations>
</tr> </tr>
) );
} };
export default Row export default Row;

View File

@ -1,120 +1,122 @@
import React, { useState, useEffect } from 'react' import type {Paginator} from '@/scripts/types';
import { hot } from 'react-hot-loader/root' import type {Line} from './types';
import { useImmer } from 'use-immer' import Loading from '@/components/Loading';
import { t } from '@/scripts/i18n' import Pagination from '@/components/Pagination';
import * as fetch from '@/scripts/net' import {t} from '@/scripts/i18n';
import { showModal, toast } from '@/scripts/notify' import * as fetch from '@/scripts/net';
import type { Paginator } from '@/scripts/types' import {showModal, toast} from '@/scripts/notify';
import Loading from '@/components/Loading' import React, {useEffect, useState} from 'react';
import Pagination from '@/components/Pagination' import {useImmer} from 'use-immer';
import type { Line } from './types' import Row from './Row';
import Row from './Row'
const Translations: React.FC = () => { function Translations() {
const [lines, setLines] = useImmer<Line[]>([]) const [lines, setLines] = useImmer<Line[]>([]);
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1) const [totalPages, setTotalPages] = useState(1);
useEffect(() => { useEffect(() => {
const getLines = async () => { const getLines = async () => {
setIsLoading(true) setIsLoading(true);
const result = await fetch.get<Paginator<Line>>('/admin/i18n/list', { const result = await fetch.get<Paginator<Line>>('/admin/i18n/list', {
page, page: page.toString(),
}) });
setLines(() => result.data) setLines(() => result.data);
setTotalPages(result.last_page) setTotalPages(result.last_page);
setIsLoading(false) setIsLoading(false);
} };
getLines()
}, [page])
const handleEdit = async (line: Line, index: number) => { getLines();
let text: string }, [page]);
try {
const { value } = await showModal({
mode: 'prompt',
text: t('admin.i18n.updating'),
input: line.text[blessing.locale],
})
text = value
} catch {
return
}
const { code, message } = await fetch.put<fetch.ResponseBody>( const handleEdit = async (line: Line, index: number) => {
`/admin/i18n/${line.id}`, let text: string;
{ text }, try {
) const {value} = await showModal({
if (code === 0) { mode: 'prompt',
toast.success(message) text: t('admin.i18n.updating'),
setLines((lines) => { input: line.text[blessing.locale],
lines[index]!.text[blessing.locale] = text });
}) text = value;
} else { } catch {
toast.error(message) return;
} }
}
const handleRemove = async (line: Line) => { const {code, message} = await fetch.put<fetch.ResponseBody>(
try { `/admin/i18n/${line.id}`,
await showModal({ {text},
text: t('admin.i18n.confirmDelete'), );
okButtonType: 'danger', if (code === 0) {
}) toast.success(message);
} catch { setLines(lines => {
return lines[index].text[blessing.locale] = text;
} });
} else {
toast.error(message);
}
};
const { message } = await fetch.del(`/admin/i18n/${line.id}`) const handleRemove = async (line: Line) => {
toast.success(message) try {
const { id } = line await showModal({
setLines((lines) => lines.filter((line) => line.id !== id)) text: t('admin.i18n.confirmDelete'),
} okButtonType: 'danger',
});
} catch {
return;
}
return ( const {message} = await fetch.del(`/admin/i18n/${line.id}`);
<> toast.success(String(message));
<div className="card-body p-0"> const {id} = line;
<table className="table table-striped"> setLines(lines => lines.filter(line => line.id !== id));
<thead> };
<tr>
<th>{t('admin.i18n.group')}</th> return (
<th>{t('admin.i18n.key')}</th> <>
<th>{t('admin.i18n.text')}</th> <div className='card-body p-0'>
<th>{t('admin.operationsTitle')}</th> <table className='table table-striped'>
</tr> <thead>
</thead> <tr>
<tbody> <th>{t('admin.i18n.group')}</th>
{isLoading ? ( <th>{t('admin.i18n.key')}</th>
<tr> <th>{t('admin.i18n.text')}</th>
<td className="text-center" colSpan={4}> <th>{t('admin.operationsTitle')}</th>
<Loading /> </tr>
</td> </thead>
</tr> <tbody>
) : lines.length === 0 ? ( {isLoading
<tr> ? (
<td className="text-center" colSpan={4}> <tr>
{t('general.noResult')} <td className='text-center' colSpan={4}>
</td> <Loading/>
</tr> </td>
) : ( </tr>
lines.map((line, i) => ( )
<Row : lines.length === 0
key={line.id} ? (
line={line} <tr>
onEdit={(line) => handleEdit(line, i)} <td className='text-center' colSpan={4}>
onRemove={handleRemove} {t('general.noResult')}
/> </td>
)) </tr>
)} )
</tbody> : lines.map((line, i) => (
</table> <Row
</div> key={line.id}
<div className="card-footer d-flex flex-row-reverse"> line={line}
<Pagination page={page} totalPages={totalPages} onChange={setPage} /> onEdit={async line => handleEdit(line, i)}
</div> onRemove={handleRemove}
</> />
) ))}
</tbody>
</table>
</div>
<div className='card-footer d-flex flex-row-reverse'>
<Pagination page={page} totalPages={totalPages} onChange={setPage}/>
</div>
</>
);
} }
export default hot(Translations) export default Translations;

View File

@ -1,6 +1,6 @@
export type Line = { export type Line = {
id: number id: number;
group: string group: string;
key: string key: string;
text: Record<string, string> text: Record<string, string>;
} };

View File

@ -1,24 +1,22 @@
import { post, ResponseBody } from '../../scripts/net' import {t} from '../../scripts/i18n';
import { showModal } from '../../scripts/notify' import {post, type ResponseBody} from '../../scripts/net';
import { t } from '../../scripts/i18n' import {showModal} from '../../scripts/notify';
export default async function handler(event: MouseEvent) { export default async function handler(event: MouseEvent) {
const button = event.target as HTMLButtonElement const button = event.target as HTMLButtonElement;
button.disabled = true button.disabled = true;
const text = button.textContent const text = button.textContent;
button.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t( button.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t('admin.downloading')}`;
'admin.downloading',
)}`
const { code, message }: ResponseBody = await post('/admin/update/download') const {code, message}: ResponseBody = await post('/admin/update/download');
button.textContent = text button.textContent = text;
button.disabled = false button.disabled = false;
await showModal({ mode: 'alert', text: message }) await showModal({mode: 'alert', text: message});
if (code === 0) { if (code === 0) {
location.href = '/' location.href = '/';
} }
} }
const button = document.querySelector<HTMLButtonElement>('#update') const button = document.querySelector<HTMLButtonElement>('#update');
button?.addEventListener('click', handler) button?.addEventListener('click', handler);

View File

@ -1,164 +1,167 @@
import React from 'react'
import { t } from '@/scripts/i18n' import type {User} from '@/scripts/types';
import type { User } from '@/scripts/types' import {t} from '@/scripts/i18n';
import { Box, Icon, InfoTable } from './styles' import clsx from 'clsx';
import {Box, Icon, InfoTable} from './styles';
import { import {
humanizePermission, canModifyPermission,
verificationStatusText, canModifyUser,
canModifyUser, humanizePermission,
canModifyPermission, verificationStatusText,
} from './utils' } from './utils';
import clsx from 'clsx'
interface Props { type Props = {
user: User readonly user: User;
currentUser: User readonly currentUser: User;
onEmailChange(): void onEmailChange: () => void;
onNicknameChange(): void onNicknameChange: () => void;
onScoreChange(): void onScoreChange: () => void;
onPermissionChange(): void onPermissionChange: () => void;
onVerificationToggle(): void onVerificationToggle: () => void;
onPasswordChange(): void onPasswordChange: () => void;
onDelete(): void onDelete: () => void;
} };
const Card: React.FC<Props> = (props) => { const Card: React.FC<Props> = props => {
const { user, currentUser } = props const {user, currentUser} = props;
const isDarkMode = document.body.classList.contains('dark-mode') const isDarkMode = document.body.classList.contains('dark-mode');
const avatar = `${blessing.base_url}/avatar/user/${user.uid}` const avatar = `${blessing.base_url}/avatar/user/${user.uid}`;
const avatarPNG = `${avatar}?png` const avatarPNG = `${avatar}?png`;
const canModify = canModifyUser(user, currentUser) const canModify = canModifyUser(user, currentUser);
return ( return (
<Box className={clsx('info-box', { 'bg-gray-dark': isDarkMode })}> <Box className={clsx('info-box', {'bg-gray-dark': isDarkMode})}>
<Icon py> <Icon py>
<picture> <picture>
<source srcSet={avatar} type="image/webp" /> <source srcSet={avatar} type='image/webp'/>
<img className="bs-avatar" src={avatarPNG} /> <img className='bs-avatar' src={avatarPNG}/>
</picture> </picture>
</Icon> </Icon>
<div className="info-box-content"> <div className='info-box-content'>
<div className="row"> <div className='row'>
<div className="col-10"> <div className='col-10'>
<b>{user.nickname}</b> <b>{user.nickname}</b>
</div> </div>
<div className="col-2"> <div className='col-2'>
{canModify && ( {canModify && (
<div className="float-right dropdown"> <div className='float-right dropdown'>
<a <a
className="text-gray" className='text-gray'
href="#" href='#'
data-toggle="dropdown" data-toggle='dropdown'
aria-expanded="false" aria-expanded='false'
> >
<i className="fas fa-cog"></i> <i className='fas fa-cog'/>
</a> </a>
<div className="dropdown-menu dropdown-menu-right"> <div className='dropdown-menu dropdown-menu-right'>
<a <a
href="#" href='#'
className="dropdown-item" className='dropdown-item'
onClick={props.onEmailChange} onClick={props.onEmailChange}
> >
<i className="fas fa-at mr-2"></i> <i className='fas fa-at mr-2'/>
{t('admin.changeEmail')} {t('admin.changeEmail')}
</a> </a>
<a <a
href="#" href='#'
className="dropdown-item" className='dropdown-item'
onClick={props.onNicknameChange} onClick={props.onNicknameChange}
> >
<i className="fas fa-signature mr-2"></i> <i className='fas fa-signature mr-2'/>
{t('admin.changeNickName')} {t('admin.changeNickName')}
</a> </a>
<a <a
href="#" href='#'
className="dropdown-item" className='dropdown-item'
onClick={props.onPasswordChange} onClick={props.onPasswordChange}
> >
<i className="fas fa-asterisk mr-2"></i> <i className='fas fa-asterisk mr-2'/>
{t('admin.changePassword')} {t('admin.changePassword')}
</a> </a>
<div className="dropdown-divider"></div> <div className='dropdown-divider'/>
<a <a
href="#" href='#'
className="dropdown-item" className='dropdown-item'
onClick={props.onScoreChange} onClick={props.onScoreChange}
> >
<i className="fas fa-coins mr-2"></i> <i className='fas fa-coins mr-2'/>
{t('admin.changeScore')} {t('admin.changeScore')}
</a> </a>
{canModifyPermission(user, currentUser) && ( {canModifyPermission(user, currentUser) && (
<a <a
href="#" href='#'
className="dropdown-item" className='dropdown-item'
onClick={props.onPermissionChange} onClick={props.onPermissionChange}
> >
<i className="fas fa-user-secret mr-2"></i> <i className='fas fa-user-secret mr-2'/>
{t('admin.changePermission')} {t('admin.changePermission')}
</a> </a>
)} )}
<a <a
href="#" href='#'
className="dropdown-item" className='dropdown-item'
onClick={props.onVerificationToggle} onClick={props.onVerificationToggle}
> >
<i className="fas fa-user-check mr-2"></i> <i className='fas fa-user-check mr-2'/>
{t('admin.toggleVerification')} {t('admin.toggleVerification')}
</a> </a>
<div className="dropdown-divider"></div> <div className='dropdown-divider'/>
{canModify && user.uid !== currentUser.uid && ( {canModify && user.uid !== currentUser.uid && (
<a <a
href="#" href='#'
className="dropdown-item dropdown-item-danger" className='dropdown-item dropdown-item-danger'
onClick={props.onDelete} onClick={props.onDelete}
> >
<i className="fas fa-trash mr-2"></i> <i className='fas fa-trash mr-2'/>
{t('admin.deleteUser')} {t('admin.deleteUser')}
</a> </a>
)} )}
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
<div> <div>
<div>UID: {user.uid}</div> <div>
<div> UID:
{t('general.user.email')} {user.uid}
{': '} </div>
<span>{user.email}</span> <div>
</div> {t('general.user.email')}
<InfoTable className="row m-2 border-top border-bottom"> {': '}
<div className="col-sm-4 py-1 text-center"> <span>{user.email}</span>
<b className="d-block">{t('general.user.score')}</b> </div>
<span className="d-block py-1">{user.score}</span> <InfoTable className='row m-2 border-top border-bottom'>
</div> <div className='col-sm-4 py-1 text-center'>
<div className="col-sm-4 py-1 text-center"> <b className='d-block'>{t('general.user.score')}</b>
<b className="d-block">{t('admin.permission')}</b> <span className='d-block py-1'>{user.score}</span>
<span className="d-block py-1"> </div>
{humanizePermission(user.permission)} <div className='col-sm-4 py-1 text-center'>
</span> <b className='d-block'>{t('admin.permission')}</b>
</div> <span className='d-block py-1'>
<div className="col-sm-4 py-1 text-center"> {humanizePermission(user.permission)}
<b className="d-block">{t('admin.verification')}</b> </span>
<span className="d-block py-1"> </div>
{verificationStatusText(user.verified)} <div className='col-sm-4 py-1 text-center'>
</span> <b className='d-block'>{t('admin.verification')}</b>
</div> <span className='d-block py-1'>
</InfoTable> {verificationStatusText(user.verified)}
<div> </span>
<small className="text-gray"> </div>
{t('general.user.register-at')} </InfoTable>
{': '} <div>
{user.register_at} <small className='text-gray'>
</small> {t('general.user.register-at')}
</div> {': '}
</div> {user.register_at}
</div> </small>
</Box> </div>
) </div>
} </div>
</Box>
);
};
export default Card export default Card;

View File

@ -1,5 +1,5 @@
import styled from '@emotion/styled' import {Breakpoint, lessThan} from '@/styles/breakpoints';
import { lessThan, Breakpoint } from '@/styles/breakpoints' import styled from '@emotion/styled';
const Header = styled.div` const Header = styled.div`
display: flex; display: flex;
@ -17,6 +17,6 @@ const Header = styled.div`
margin: 7px 0 0 0; margin: 7px 0 0 0;
} }
} }
` `;
export default Header export default Header;

View File

@ -1,61 +1,60 @@
import React from 'react' import {t} from '@/scripts/i18n';
import styled from '@emotion/styled' import styled from '@emotion/styled';
import Skeleton from 'react-loading-skeleton' import clsx from 'clsx';
import { t } from '@/scripts/i18n' import Skeleton from 'react-loading-skeleton';
import { Box, Icon, InfoTable } from './styles' import {Box, Icon, InfoTable} from './styles';
import clsx from 'clsx'
const ShrinkedSkeleton = styled(Skeleton)<{ width?: string }>` const ShrinkedSkeleton = styled(Skeleton)<{width?: string}>`
width: ${(props) => props.width}; width: ${props => props.width};
` `;
const isDarkMode = document.body.classList.contains('dark-mode') const isDarkMode = document.body.classList.contains('dark-mode');
const LoadingCard: React.FC = () => ( export default function LoadingCard() {
<Box className={clsx('info-box', { 'bg-gray-dark': isDarkMode })}> return (
<Icon> <Box className={clsx('info-box', {'bg-gray-dark': isDarkMode})}>
<Skeleton circle height={50} width={50} /> <Icon>
</Icon> <Skeleton circle height={50} width={50}/>
<div className="info-box-content"> </Icon>
<div className="row"> <div className='info-box-content'>
<div className="col-10"> <div className='row'>
<Skeleton width="140px" /> <div className='col-10'>
</div> <Skeleton width='140px'/>
<div className="col-2"></div> </div>
</div> <div className='col-2'/>
<div> </div>
<div> <div>
<Skeleton width="140px" /> <div>
</div> <Skeleton width='140px'/>
<div> </div>
<Skeleton width="140px" /> <div>
</div> <Skeleton width='140px'/>
<InfoTable className="row m-2 border-top border-bottom"> </div>
<div className="col-sm-4 py-1 text-center"> <InfoTable className='row m-2 border-top border-bottom'>
<b className="d-block">{t('general.user.score')}</b> <div className='col-sm-4 py-1 text-center'>
<span className="d-block py-1"> <b className='d-block'>{t('general.user.score')}</b>
<ShrinkedSkeleton width="30%" /> <span className='d-block py-1'>
</span> <ShrinkedSkeleton width='30%'/>
</div> </span>
<div className="col-sm-4 py-1 text-center"> </div>
<b className="d-block">{t('admin.permission')}</b> <div className='col-sm-4 py-1 text-center'>
<span className="d-block py-1"> <b className='d-block'>{t('admin.permission')}</b>
<ShrinkedSkeleton width="30%" /> <span className='d-block py-1'>
</span> <ShrinkedSkeleton width='30%'/>
</div> </span>
<div className="col-sm-4 py-1 text-center"> </div>
<b className="d-block">{t('admin.verification')}</b> <div className='col-sm-4 py-1 text-center'>
<span className="d-block py-1"> <b className='d-block'>{t('admin.verification')}</b>
<ShrinkedSkeleton width="30%" /> <span className='d-block py-1'>
</span> <ShrinkedSkeleton width='30%'/>
</div> </span>
</InfoTable> </div>
<div> </InfoTable>
<Skeleton width="180px" /> <div>
</div> <Skeleton width='180px'/>
</div> </div>
</div> </div>
</Box> </div>
) </Box>
);
export default LoadingCard }

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