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
# Append -bullseye or -buster to pin to an OS version.
# Use -bullseye variants on local arm64/Apple Silicon.
VARIANT: "8-bullseye"
VARIANT: 8-bullseye
# Optional Node.js version
NODE_VERSION: "lts/*"
NODE_VERSION: 'lts/*'
volumes:
- ..:/workspace:cached

View File

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

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
- command: gp ports await 8080 && gp preview $(gp url 8000)
github:
prebuilds:
# enable for the master/default branch (defaults to true)
master: true
# enable for all branches in this repo (defaults to false)
branches: false
# enable for pull requests coming from this repo (defaults to true)
pullRequests: true
# add a check to pull requests (defaults to true)
addCheck: true
# add a "Review in Gitpod" button as a comment to pull requests (defaults to false)
addComment: false
vscode:
extensions:
- 'editorconfig.editorconfig'
- 'eamodio.gitlens'
- 'bmewburn.vscode-intelephense-client'
- 'esbenp.prettier-vscode'
- 'jpoissonnier.vscode-styled-components'
- 'mblode.twig-language-2'
- 'felixfbecker.php-debug'
- editorconfig.editorconfig
- eamodio.gitlens
- bmewburn.vscode-intelephense-client
- esbenp.prettier-vscode
- jpoissonnier.vscode-styled-components
- mblode.twig-language-2
- felixfbecker.php-debug
ports:
- port: 8080

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

View File

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

View File

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

4743
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

2
public/.gitignore vendored
View File

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

View File

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

View File

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

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 {
initMode: boolean
}
type Props = {
readonly initMode: boolean;
};
const DarkModeButton: React.FC<Props> = ({ initMode }) => {
const [darkMode, setDarkMode] = useState(initMode)
const DarkModeButton: React.FC<Props> = ({initMode}) => {
const [darkMode, setDarkMode] = useState(initMode);
const icon = darkMode ? 'moon' : 'sun'
const icon = darkMode ? 'moon' : 'sun';
const handleClick = async () => {
setDarkMode((value) => !value)
const handleClick = async () => {
setDarkMode(value => !value);
await fetch.put('/user/dark-mode')
document.body.classList.toggle('dark-mode')
}
await fetch.put('/user/dark-mode');
document.body.classList.toggle('dark-mode');
};
return (
<a className="nav-link" href="#" role="button" onClick={handleClick}>
<i className={`fas fa-${icon}`}></i>
</a>
)
}
return (
<a className='nav-link' href='#' role='button' onClick={handleClick}>
<i className={`fas fa-${icon}`}/>
</a>
);
};
export default DarkModeButton
export default DarkModeButton;

View File

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

View File

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

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 = () => (
<div className="container text-center" title="Loading...">
<i className="fas fa-sync fa-spin"></i>
</div>
)
export default Loading
export default Loading;

View File

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

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 {
showInput: boolean
}
import ModalContent, {type Props as ContentProps} from './ModalContent';
import ModalInput, {
type
InternalProps as InputInteralProps,
type
Props as InputProps,
} from './ModalInput';
export type Props = ContentProps & InputProps
type InternalProps = {
readonly showInput: boolean;
};
const ModalBody: React.FC<InternalProps & InputInteralProps & Props> = (
props,
) => {
return (
<div className="modal-body">
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
{props.children}
</ModalContent>
{props.showInput && <ModalInput {...props} />}
</div>
)
}
export type Props = ContentProps & InputProps;
export default ModalBody
const ModalBody: React.FC<InternalProps & InputInteralProps & Props> = props => (
<div className='modal-body'>
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
{props.children}
</ModalContent>
{props.showInput && <ModalInput {...props}/>}
</div>
);
export default ModalBody;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 * as ReactDOM from 'react-dom'
import DarkModeButton from '@/components/DarkModeButton'
import DarkModeButton from '@/components/DarkModeButton';
import ReactDOM from 'react-dom';
const el = document.querySelector('#toggle-dark-mode')
if (el) {
const initMode = document.body.classList.contains('dark-mode')
ReactDOM.render(<DarkModeButton initMode={initMode} />, el)
const element = document.querySelector('#toggle-dark-mode');
if (element) {
const initMode = document.body.classList.contains('dark-mode');
ReactDOM.render(<DarkModeButton initMode={initMode}/>, element);
}

View File

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

View File

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

View File

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

View File

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

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 {
const [value, setValue] = useState<T>(defaultValue!)
const [value, setValue] = useState<T>(defaultValue!);
useEffect(() => {
setValue(blessing.extra[key] as T)
}, [key])
useEffect(() => {
setValue(blessing.extra[key] as T);
}, [key]);
return value
return value;
}

View File

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

View File

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

View File

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

View File

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

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) {
const [value, setValue] = useState<T>(initialValue)
const ref = useRef<T>(value)
const [dest, setDest] = useState<T>(initialValue)
const [value, setValue] = useState<T>(initialValue);
const reference = useRef<T>(value);
const [destination, setDestination] = useState<T>(initialValue);
useEffect(() => {
function animate() {
requestAnimationFrame(animate)
TWEEN.update()
setValue(ref.current)
}
useEffect(() => {
function animate() {
requestAnimationFrame(animate);
TWEEN.update();
setValue(reference.current);
}
const tween = new TWEEN.Tween(ref)
tween.to({ current: dest }, 1000).start()
animate()
}, [dest])
const tween = new TWEEN.Tween(reference);
tween.to({current: destination}, 1000).start();
animate();
}, [destination]);
return [value, setDest] as const
return [value, setDestination] as const;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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`
width: 48%;
@ -8,4 +8,4 @@ export const Box = styled.div`
${breakpoints.lessThan(breakpoints.Breakpoint.lg)} {
width: 98%;
}
`
`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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