cleanup: wip 6

This commit is contained in:
Zephyr Lykos 2025-01-19 14:15:08 +08:00
parent ea5be502b3
commit 590ed9ce73
No known key found for this signature in database
GPG Key ID: D3E9D31E2F77F04D
239 changed files with 5284 additions and 6077 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

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

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

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}/"
}
]
}
]
}

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,15 +1,16 @@
{
"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"
},
"license": "MIT",
"author": "printempw",
"type": "module",
"scripts": {
"build": "vite build",
"build:urls": "ts-node tools/generateUrls.ts",
@ -22,109 +23,64 @@
"iOS >= 12.5",
"Chrome >= 87"
],
"eslintConfig": {
"env": {
"es2024": true
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"extends": [
"xo",
"xo-react",
"plugin:react/jsx-runtime",
"./node_modules/xo/config/plugins.cjs"
],
"rules": {
"import/extensions": "off",
"import/no-named-as-default": "off",
"n/file-extension-in-import": "off",
"unicorn/filename-case": "off",
"n/prefer-global/process": "off"
},
"overrides": [
{
"files": [
"*.ts",
"*.tsx"
],
"extends": [
"xo-typescript"
],
"rules": {
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/consistent-type-definitions": "warn",
"@typescript-eslint/naming-convention": "warn"
}
}
],
"ignorePatterns": [
"dist",
"public"
],
"root": true
},
"resolutions": {
"kleur": "^4.1.3"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.0.0",
"@fortawesome/fontawesome-free": "^6.3.0",
"@tweenjs/tween.js": "^23.1.1",
"admin-lte": "next",
"@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.0",
"echarts": "^5.5.0",
"immer": "^10.0.3",
"clsx": "^2.1.1",
"downshift": "^9.0.8",
"echarts": "^5.6.0",
"immer": "^10.1.1",
"jquery": "^3.6.0",
"lodash-es": "^4.0.8",
"nanoid": "^5.0.6",
"nanoid": "^5.0.9",
"prompts": "^2.4.0",
"react": "^18.2.0",
"react-autosuggest": "^10.0.2",
"react-dom": "^18.2.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-draggable": "^4.4.2",
"react-loading-skeleton": "^3.4.0",
"react-use": "^17.5.0",
"react-loading-skeleton": "^3.5.0",
"react-use": "^17.6.0",
"reaptcha": "^1.7.2",
"rxjs": "^7.8.1",
"skinview-utils": "^0.7.1",
"skinview3d": "^3.0.0-alpha.1",
"spectre.css": "npm:@angular-package/spectre.css",
"use-immer": "^0.9.0"
"skinview3d": "^3.1.0",
"spectre.css": "github:angular-package/spectre.css",
"use-immer": "^0.11.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@tsconfig/vite-react": "^3.0.0",
"@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.13",
"@types/jquery": "^3.5.32",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.0.6",
"@types/prompts": "^2.0.9",
"@types/react": "^18.2.62",
"@types/react-autosuggest": "^10.1.11",
"@types/react-dom": "^18.2.19",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/tween.js": "^18.5.0",
"@vitejs/plugin-react-swc": "^3.6.0",
"autoprefixer": "^10.4.18",
"browserslist": "^4.23.0",
"@vitejs/plugin-react-swc": "^3.7.2",
"autoprefixer": "^10.4.20",
"browserslist": "^4.24.4",
"browserslist-to-esbuild": "^2.1.1",
"eslint-config-xo-react": "^0.27.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"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.0.2",
"postcss": "^8.4.35",
"sass": "^1.71.1",
"typescript": "^5.3.3",
"vite": "^5.1.5",
"vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-wasm": "^3.3.0",
"vitest": "^1.3.1",
"xo": "^0.57.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"
},
"postcss": {
"plugins": {

1
public/.gitignore vendored
View File

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

View File

@ -1,4 +1,3 @@
type AlertType = 'success' | 'info' | 'warning' | 'danger';
const icons = new Map<AlertType, string>([
@ -13,16 +12,17 @@ type Properties = {
readonly children?: React.ReactNode;
};
const Alert: React.FC<Properties> = properties => {
const {type} = properties;
const Alert: React.FC<Properties> = ({type, children}) => {
const icon = icons.get(type);
return properties.children ? (
<div className={`alert alert-${type}`}>
<i className={`icon fas fa-${icon}`}/>
{properties.children}
</div>
) : null;
return children === ''
? null
: (
<div className={`alert alert-${type}`}>
<i className={`icon fas fa-${icon}`}/>
{children}
</div>
);
};
export default Alert;

View File

@ -1,11 +1,10 @@
type Properties = {
readonly title?: string;
readonly onClick: React.MouseEventHandler<HTMLAnchorElement>;
};
const ButtonEdit: React.FC<Properties> = properties => (
<a href='#' title={properties.title} className='ml-2' onClick={properties.onClick}>
const ButtonEdit: React.FC<Properties> = ({title, onClick}) => (
<a href='#' title={title} className='ml-2' onClick={onClick}>
<i className='fas fa-edit'/>
</a>
);

View File

@ -1,11 +1,10 @@
/** @jsxImportSource @emotion/react */
import 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 React from 'react';
import Reaptcha from 'reaptcha';
const eventId = Symbol();
const eventId = Symbol('EventId');
type State = {
value: string;
@ -16,20 +15,22 @@ type State = {
class Captcha extends React.Component<Record<string, unknown>, State> {
state: State;
ref: React.MutableRefObject<Reaptcha | undefined>;
// eslint-disable-next-line ts/no-restricted-types
ref: React.RefObject<Reaptcha | null>;
constructor(properties: Record<string, unknown>) {
super(properties);
this.state = {
value: '',
time: Date.now(),
sitekey: blessing.extra.recaptcha,
invisible: blessing.extra.invisible,
sitekey: blessing.extra.recaptcha as string,
invisible: blessing.extra.invisible as boolean,
};
this.ref = React.createRef();
this.ref = React.createRef<Reaptcha>();
}
execute = async () => {
// eslint-disable-next-line react/no-unused-class-component-members
async execute() {
const recaptcha = this.ref.current;
if (recaptcha && this.state.invisible) {
return new Promise<string>(resolve => {
@ -37,21 +38,22 @@ class Captcha extends React.Component<Record<string, unknown>, State> {
resolve(value);
off();
});
recaptcha.execute();
void recaptcha.execute();
});
}
return this.state.value;
};
}
reset = () => {
// eslint-disable-next-line react/no-unused-class-component-members
reset() {
const recaptcha = this.ref.current;
if (recaptcha) {
recaptcha.reset();
void recaptcha.reset();
} else {
this.setState({time: Date.now()});
}
};
}
handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({value: event.target.value});
@ -67,37 +69,39 @@ class Captcha extends React.Component<Record<string, unknown>, State> {
};
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}
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>
<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>
);
)
: (
<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>
);
}
}

View File

@ -1,5 +1,5 @@
import {useState} from 'react';
import * as fetch from '@/scripts/net';
import {useState} from 'react';
type Properties = {
readonly initMode: boolean;

View File

@ -1,10 +1,9 @@
/** @jsxImportSource @emotion/react */
import {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 {css} from '@emotion/react';
import clsx from 'clsx';
import {useCombobox} from 'downshift';
import {useEffect, useState} from 'react';
const styles = css`
.dropdown-menu li {
@ -14,75 +13,53 @@ const styles = css`
const domainNames = new Set(['qq.com', '163.com', 'gmail.com', 'hotmail.com']);
type Properties = Omit<Autosuggest.InputProps<string>, 'onChange'> & {
onChange(value: string): void;
type Properties = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
onChange: (value: string) => void;
};
const EmailSuggestion: React.FC<Properties> = properties => {
const [suggestions, setSuggestions] = useState<string[]>([]);
const EmailSuggestion: React.FC<Properties> = props => {
useEffect(() => {
emit('emailDomainsSuggestion', domainNames);
}, []);
const [inputItems, setInputItems] = useState<string[]>([]);
const handleSuggestionsFetchRequested: Autosuggest.SuggestionsFetchRequested
= ({value}) => {
const segments = value.split('@');
setSuggestions([...domainNames].map(name => `${segments[0]}@${name}`));
};
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 handleSuggestionsClearRequested = () => {
setSuggestions([]);
};
const {onChange} = props;
onChange(value);
},
});
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) => {
properties.onChange(event.newValue);
};
const renderInputComponent = (
properties_: Omit<Autosuggest.InputProps<string>, 'onChange'>,
) => (
<div className='input-group'>
<input className='form-control' {...properties_}/>
<div className='input-group-append'>
<div className='input-group-text'>
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>
);
return (
<div css={styles}>
<Autosuggest
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
shouldRenderSuggestions={shouldRenderSuggestions}
inputProps={({...properties, onChange: handleChange})}
renderInputComponent={renderInputComponent}
theme={{
container: 'mb-3',
suggestion: 'dropdown-item',
suggestionsContainer: 'dropdown',
suggestionsList: `dropdown-menu ${suggestions.length > 0 ? 'show' : ''}`,
suggestionHighlighted: 'active',
}}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
/>
<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>
);
};

View File

@ -1,7 +1,6 @@
/** @jsxImportSource @emotion/react */
import {useRef} from 'react';
import {css} from '@emotion/react';
import {t} from '@/scripts/i18n';
import {css} from '@emotion/react';
import {useRef} from 'react';
const hideRawBrowseButton = css`
::after {
@ -12,7 +11,7 @@ const hideRawBrowseButton = css`
type Properties = {
file: File | undefined;
accept?: string;
onChange(event: React.ChangeEvent<HTMLInputElement>): void;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
};
const FileInput: React.FC<Properties> = properties => {

View File

@ -1,16 +1,16 @@
import {useState, useEffect, useRef} from 'react';
import $ from 'jquery';
import 'bootstrap';
import {Modal as BootstrapModal} from 'bootstrap';
import clsx from 'clsx';
import {useEffect, useRef, useState} from 'react';
import {t} from '../scripts/i18n';
import ModalHeader, {type Props as HeaderProperties} from './ModalHeader';
import ModalBody, {type Props as BodyProperties} from './ModalBody';
import ModalFooter, {type Props as FooterProperties} from './ModalFooter';
import ModalHeader, {type Props as HeaderProperties} from './ModalHeader';
type BasicOptions = {
readonly mode?: 'alert' | 'confirm' | 'prompt';
readonly show?: boolean;
readonly input?: string;
validator?(value: any): string | boolean | undefined;
validator?: (value: any) => string | boolean | undefined;
readonly type?: string;
readonly showHeader?: boolean;
readonly center?: boolean;
@ -23,9 +23,9 @@ type Properties = {
readonly id?: string;
readonly children?: React.ReactNode;
readonly footer?: React.ReactNode;
onConfirm?(payload: {value: string}): void;
onDismiss?(): void;
onClose?(): void;
onConfirm?: (payload: {value: string}) => void;
onDismiss?: () => void;
onClose?: () => void;
};
export type ModalResult = {
@ -49,14 +49,36 @@ const Modal: React.FC<ModalOptions & Properties> = properties => {
cancelButtonText = t('general.cancel'),
cancelButtonType = 'secondary',
flexFooter = false,
footer,
show,
onClose,
onDismiss,
id,
validator,
onConfirm,
children,
choices,
dangerousHTML: html,
} = properties;
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, onClose} = properties;
useEffect(() => {
if (!reference.current) {
return;
}
const _modal = new BootstrapModal(reference.current);
setModal(_modal);
return () => {
_modal.dispose();
};
}, [reference]);
useEffect(() => {
if (!show) {
@ -64,23 +86,26 @@ const Modal: React.FC<ModalOptions & Properties> = properties => {
}
const onHidden = () => {
onClose ? onClose() : void 0;
onClose?.();
};
const element = $(reference.current!);
element.on('hidden.bs.modal', onHidden);
const element = reference.current;
if (!element) {
return;
}
element.addEventListener('hidden.bs.modal', onHidden);
return () => {
element.off('hidden.bs.modal', onHidden);
element.removeEventListener('hidden.bs.modal', onHidden);
};
}, [show, onClose]);
}, [reference, show, onClose]);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
};
const confirm = () => {
const {validator} = properties;
if (typeof validator === 'function') {
const result = validator(value);
if (typeof result === 'string') {
@ -90,49 +115,54 @@ const Modal: React.FC<ModalOptions & Properties> = properties => {
}
}
properties.onConfirm?.({value});
$(reference.current!).modal('hide');
onConfirm?.({value});
modal?.hide();
// The "hidden.bs.modal" event can't be trigged automatically when testing.
if (process.env.NODE_ENV === 'test') {
if (import.meta.env.NODE_ENV === 'test') {
$(reference.current!).trigger('hidden.bs.modal');
}
};
const dismiss = () => {
properties.onDismiss?.();
$(reference.current!).modal('hide');
onDismiss?.();
modal?.hide();
if (process.env.NODE_ENV === 'test') {
if (import.meta.env.NODE_ENV === 'test') {
$(reference.current!).trigger('hidden.bs.modal');
}
};
useEffect(() => {
if (show) {
setTimeout(() => $(reference.current!).modal('show'), 50);
if (show && modal) {
const timeout = setTimeout(() => {
modal.show();
}, 50);
return () => {
clearTimeout(timeout);
};
}
}, [show]);
}, [show, modal]);
if (!show) {
return null;
}
return (
<div ref={reference} id={properties.id} className='modal fade' role='dialog'>
<div ref={reference} id={id} className='modal fade' role='dialog'>
<div
className={`modal-dialog ${center ? 'modal-dialog-centered' : ''}`}
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={properties.dangerousHTML}
dangerousHTML={html}
showInput={mode === 'prompt'}
value={value}
choices={properties.choices}
choices={choices}
inputType={inputType}
inputMode={inputMode}
placeholder={placeholder}
@ -140,7 +170,7 @@ const Modal: React.FC<ModalOptions & Properties> = properties => {
validatorMessage={validatorMessage}
onChange={handleInputChange}
>
{properties.children}
{children}
</ModalBody>
<ModalFooter
showCancelButton={mode !== 'alert'}
@ -152,7 +182,7 @@ const Modal: React.FC<ModalOptions & Properties> = properties => {
onConfirm={confirm}
onDismiss={dismiss}
>
{properties.footer}
{footer}
</ModalFooter>
</div>
</div>

View File

@ -2,8 +2,9 @@
import ModalContent, {type Props as ContentProperties} from './ModalContent';
import ModalInput, {
type
Props as InputProperties, type
InternalProps as InputInteralProperties,
type
Props as InputProperties,
} from './ModalInput';
type InternalProperties = {

View File

@ -13,9 +13,8 @@ const ModalContent: React.FC<Props> = properties => {
if (properties.text) {
return (
<>
{properties.text.split(/\r?\n/).map((line, i) => (
<p key={i}>{line}</p>
))}
{properties.text.split(/\r?\n/).map((line, i) =>
<p key={i}>{line}</p>)}
</>
);
}

View File

@ -10,8 +10,8 @@ export type Props = {
type InternalProperties = {
readonly showCancelButton: boolean;
onConfirm?(): void;
onDismiss?(): void;
onConfirm?: () => void;
onDismiss?: () => void;
};
const ModalFooter: React.FC<InternalProperties & Props> = properties => {
@ -22,29 +22,29 @@ const ModalFooter: React.FC<InternalProperties & Props> = properties => {
const footerClass = classes.join(' ');
return properties.children ? (
<div className={footerClass}>{properties.children}</div>
) : (
<div className={footerClass}>
{properties.showCancelButton && (
return properties.children
? <div className={footerClass}>{properties.children}</div>
: (
<div className={footerClass}>
{properties.showCancelButton && (
<button
type='button'
className={`btn btn-${properties.cancelButtonType}`}
data-dismiss='modal'
onClick={properties.onDismiss}
>
{properties.cancelButtonText}
</button>
)}
<button
type='button'
className={`btn btn-${properties.cancelButtonType}`}
data-dismiss='modal'
onClick={properties.onDismiss}
className={`btn btn-${properties.okButtonType}`}
onClick={properties.onConfirm}
>
{properties.cancelButtonText}
{properties.okButtonText}
</button>
)}
<button
type='button'
className={`btn btn-${properties.okButtonType}`}
onClick={properties.onConfirm}
>
{properties.okButtonText}
</button>
</div>
);
</div>
);
};
export default ModalFooter;

View File

@ -4,24 +4,24 @@ export type Props = {
};
type InternalProperties = {
onDismiss?(): void;
onDismiss?: () => void;
readonly show?: boolean;
};
const ModalHeader: React.FC<Props & InternalProperties> = properties =>
properties.show ? (
<div className='modal-header'>
<h5 className='modal-title'>{properties.title}</h5>
<button
type='button'
className='close'
data-dismiss='modal'
aria-label='Close'
onClick={properties.onDismiss}
>
<span aria-hidden>&times;</span>
</button>
</div>
) : null;
const ModalHeader: React.FC<Props & InternalProperties> = ({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;

View File

@ -15,36 +15,38 @@ export type InternalProps = {
const ModalInput: React.FC<InternalProps & Props> = properties => (
<>
{properties.inputType === 'radios' && properties.choices ? (
<>
{properties.choices.map(choice => (
<div key={choice.value}>
<input
type='radio'
name='modal-radios'
id={`modal-radio-${choice.value}`}
value={choice.value}
checked={choice.value === properties.value}
onChange={properties.onChange}
/>
<label htmlFor={`modal-radio-${choice.value}`} className='ml-1'>
{choice.text}
</label>
</div>
))}
</>
) : (
<div className='form-group'>
<input
value={properties.value}
type={properties.inputType}
inputMode={properties.inputMode}
className='form-control'
placeholder={properties.placeholder}
onChange={properties.onChange}
/>
</div>
)}
{properties.inputType === 'radios' && properties.choices
? (
<>
{properties.choices.map(choice => (
<div key={choice.value}>
<input
type='radio'
name='modal-radios'
id={`modal-radio-${choice.value}`}
value={choice.value}
checked={choice.value === properties.value}
onChange={properties.onChange}
/>
<label htmlFor={`modal-radio-${choice.value}`} className='ml-1'>
{choice.text}
</label>
</div>
))}
</>
)
: (
<div className='form-group'>
<input
value={properties.value}
type={properties.inputType}
inputMode={properties.inputMode}
className='form-control'
placeholder={properties.placeholder}
onChange={properties.onChange}
/>
</div>
)}
{properties.invalid && (
<div className='alert alert-danger'>
<i className='icon far fa-times-circle'/>

View File

@ -1,11 +1,11 @@
import PaginationItem from './PaginationItem';
import {t} from '@/scripts/i18n';
import PaginationItem from './PaginationItem';
type Properties = {
readonly page: number;
readonly totalPages: number;
onChange(page: number): void | Promise<void>;
onChange: (page: number) => void | Promise<void>;
};
const labels = {
@ -32,8 +32,8 @@ const Pagination: React.FC<Properties> = properties => {
{t('vendor.datatable.prev')}
</span>
</PaginationItem>
{totalPages < 8 ? (
Array.from({length: totalPages}).map((_, i) => (
{totalPages < 8
? Array.from({length: totalPages}).map((_, i) => (
<PaginationItem
key={i}
className='d-none d-sm-block'
@ -43,33 +43,10 @@ const Pagination: React.FC<Properties> = properties => {
{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 => (
: (
<>
{page < 4
? [1, 2, 3, 4].map(n => (
<PaginationItem
key={n}
className='d-none d-sm-block'
@ -78,15 +55,37 @@ const Pagination: React.FC<Properties> = properties => {
>
{n}
</PaginationItem>
))}
<PaginationItem disabled className='d-none d-sm-block'>
...
</PaginationItem>
</>
)}
{totalPages - page < 3 ? (
[totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map(
n => (
))
: (
<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'
@ -95,18 +94,17 @@ const Pagination: React.FC<Properties> = properties => {
>
{n}
</PaginationItem>
),
)
) : (
<PaginationItem
className='d-none d-sm-block'
onClick={async () => onChange(totalPages)}
>
{totalPages}
</PaginationItem>
)}
</>
)}
))
: (
<PaginationItem
className='d-none d-sm-block'
onClick={async () => onChange(totalPages)}
>
{totalPages}
</PaginationItem>
)}
</>
)}
<PaginationItem
title={t('vendor.datatable.next')}
disabled={page === totalPages}

View File

@ -4,7 +4,7 @@ type Properties = {
readonly active?: boolean;
readonly title?: string;
readonly className?: string;
onClick?(): void;
onClick?: () => void;
readonly children?: React.ReactNode;
};

View File

@ -1,14 +1,12 @@
/** @jsxImportSource @emotion/react */
import {useState, useEffect} from 'react';
import {css} from '@emotion/react';
import {useEffect, useState} from 'react';
export type ToastType = 'success' | 'info' | 'warning' | 'error';
type Properties = {
readonly type: ToastType;
readonly distance: number;
onClose(): void | Promise<void>;
onClose: () => void | Promise<void>;
readonly children: React.ReactNode;
};

View File

@ -1,11 +1,11 @@
/** @jsxImportSource @emotion/react */
import {useState, useEffect, useRef} from 'react';
import {useMeasure} from 'react-use';
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 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';
@ -13,9 +13,7 @@ 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 * as breakpoints from '@/styles/breakpoints';
import * as cssUtils from '@/styles/utils';
import {t} from '@/scripts/i18n';
import SkinSteve from '../../../misc/textures/steve.png';
const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7];
export const PICTURES_COUNT = backgrounds.length;
@ -227,9 +225,8 @@ const Viewer: React.FC<Properties> = properties => {
<div className='d-flex justify-content-between'>
<h3 className='card-title'>
<span>{t('general.texturePreview')}</span>
{properties.showIndicator && (
<span className='badge bg-olive ml-1'>{indicator}</span>
)}
{properties.showIndicator
&& <span className='badge bg-olive ml-1'>{indicator}</span>}
</h3>
<div>
<ActionButton
@ -238,14 +235,14 @@ const Viewer: React.FC<Properties> = properties => {
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'
@ -256,14 +253,14 @@ const Viewer: React.FC<Properties> = properties => {
: 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>

View File

@ -1,26 +1,23 @@
import $ from 'jquery';
import React from 'react';
import ReactDOM from 'react-dom';
import {createRoot} from 'react-dom/client';
import $ from 'jquery';
// eslint-disable-next-line import/no-unassigned-import
import './scripts/app';
import routes from './scripts/route';
import './scripts/app';
// eslint-disable-next-line @typescript-eslint/naming-convention
Object.assign(window, {React, ReactDOM, $});
const route = routes.find(route =>
new RegExp(`^${route.path}$`, 'i').test(blessing.route),
);
new RegExp(`^${route.path}$`, 'i').test(blessing.route));
if (route) {
if (route.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 Component = React.lazy(route.react as () => Promise<{default: React.ComponentType}>);
const container = typeof route.el === 'string'
? document.querySelector(route.el)
@ -28,11 +25,11 @@ if (route) {
const root = createRoot(container!);
root.render(
<React.StrictMode>
<React.Suspense fallback={route.frame?.() ?? ''}>
<React.StrictMode>
<React.Suspense fallback={route.frame?.() ?? ''}>
<Component/>
</React.Suspense>
</React.StrictMode>,
);
</React.StrictMode>
);
}
}

View File

@ -1,4 +1,5 @@
/* eslint-disable import/no-unassigned-import */
import {Tooltip} from 'bootstrap';
import '@popperjs/core';
import 'admin-lte';
import './extra';
import './i18n';
@ -10,5 +11,5 @@ import './logout';
import './darkMode';
window.addEventListener('load', () => {
$('[data-toggle="tooltip"]').tooltip();
[...document.querySelectorAll('[data-toggle="tooltip"]')].map(el => new Tooltip(el));
});

View File

@ -1,5 +1,5 @@
import ReactDOM from 'react-dom';
import DarkModeButton from '@/components/DarkModeButton';
import ReactDOM from 'react-dom';
const element = document.querySelector('#toggle-dark-mode');
if (element) {

View File

@ -1,5 +1,5 @@
import ReactDOM from 'react-dom';
import EmailVerification from '@/views/widgets/EmailVerification';
import ReactDOM from 'react-dom';
const container = document.querySelector('#email-verification');

View File

@ -1,7 +1,6 @@
const bus = new Map<string | symbol, Set<(...args: any[]) => void>>();
const bus = new Map<string | symbol, Set<CallableFunction>>();
export function on(event: string | symbol, listener: CallableFunction) {
export function on(event: string | symbol, listener: (...args: any[]) => void) {
if (!bus.has(event)) {
bus.set(event, new Set());
}
@ -15,7 +14,9 @@ export function on(event: string | symbol, listener: CallableFunction) {
}
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};

View File

@ -1,4 +1,4 @@
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!);

View File

@ -1,4 +1,4 @@
import {useState, useEffect} from 'react';
import {useEffect, useState} from 'react';
export default function useIsLargeScreen() {
const [isLarge, setIsLarge] = useState(false);

View File

@ -1,4 +1,4 @@
import {useState, useEffect} from 'react';
import {useEffect, useState} from 'react';
import * as fetch from '../net';
import {type Texture, TextureType} from '../types';

View File

@ -1,5 +1,5 @@
import {useState, useEffect, useRef} from 'react';
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);

View File

@ -2,14 +2,17 @@ type I18nTable = {
[key: string]: string | I18nTable | undefined;
};
export function t(key: string, parameters: Record<string, string> = Object.create(null)): string {
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) {
if (middle === undefined) {
return key;
}
@ -21,7 +24,7 @@ export function t(key: string, parameters: Record<string, string> = Object.creat
}
for (const slot of Object.keys(parameters)) {
(result = result.replace(`:${slot}`, parameters[slot] ?? `%{${slot}}`));
result = result.replace(`:${slot}`, parameters[slot] ?? `%{${slot}}`);
}
return result;

View File

@ -1,7 +1,7 @@
declare let __webpack_public_path__: string;
declare const __blessing_public_path__: string;
if (process.env.NODE_ENV === 'development') {
if (import.meta.env.NODE_ENV === 'development') {
__webpack_public_path__ = __blessing_public_path__;
} else {
const link = document.querySelector<HTMLLinkElement>('link#cdn-host');

View File

@ -1,5 +1,5 @@
import {post} from './net';
import {t} from './i18n';
import {post} from './net';
import {showModal} from './notify';
import urls from './urls';

View File

@ -1,17 +1,18 @@
import ReactDOM from 'react-dom';
import {createRoot} from 'react-dom/client';
import Modal, {type ModalOptions, type ModalResult} from '../components/Modal';
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);
root.unmount();
container.remove();
};
ReactDOM.render(
root.render((
<Modal
{...options}
show
@ -19,8 +20,7 @@ export async function showModal(options: ModalOptions = {}): Promise<ModalResult
onConfirm={resolve}
onDismiss={reject}
onClose={handleClose}
/>,
container,
);
/>
));
});
}

View File

@ -1,6 +1,6 @@
import {emit} from './event';
import {showModal} from './notify';
import {t} from './i18n';
import {showModal} from './notify';
export type ResponseBody<T = undefined> = {
code: number;
@ -26,9 +26,7 @@ export const init: RequestInit = {
};
function retrieveToken() {
const csrfField = document.querySelector<HTMLMetaElement>(
'meta[name="csrf-token"]',
);
const csrfField = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]');
return csrfField?.content || '';
}

View File

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

View File

@ -3,7 +3,7 @@ import {Toast} from './toast';
export const toast = new Toast();
if (process.env.NODE_ENV === 'test') {
if (import.meta.env.NODE_ENV === 'test') {
afterEach(() => {
toast.clear();
});

View File

@ -9,8 +9,8 @@ function checkPixel(
return (
imageData.data[0] === 0
&& imageData.data[1] === 0
&& imageData.data[2] === 0
&& imageData.data[1] === 0
&& imageData.data[2] === 0
);
}

View File

@ -1,14 +1,14 @@
import React, {useState, useEffect} from 'react';
import ReactDOM from 'react-dom';
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[];
const TOAST_EVENT = Symbol('toast');
const CLEAR_EVENT = Symbol('clear');
const ToastEvent = Symbol('toast');
const ClearEvent = Symbol('clear');
export function ToastContainer() {
const [queue, setQueue] = useState<ToastQueue>([]);
@ -18,17 +18,19 @@ export function ToastContainer() {
};
useEffect(() => {
const off1 = emitter.on(TOAST_EVENT, (toast: QueueElement) => {
const off1 = emitter.on(ToastEvent, (toast: QueueElement) => {
setQueue(queue => {
queue.push(toast);
return [...queue];
});
// Effect dependency is empty
// eslint-disable-next-line react-web-api/no-leaked-timeout
setTimeout(() => {
handleClose(toast.id);
}, 3100);
});
const off2 = emitter.on(CLEAR_EVENT, () => {
const off2 = emitter.on(ClearEvent, () => {
setQueue([]);
});
@ -44,7 +46,7 @@ export function ToastContainer() {
<ToastBox
key={element.id}
type={element.type}
distance={50 + i * 70}
distance={50 + (i * 70)}
onClose={() => {
handleClose(element.id);
}}
@ -58,40 +60,42 @@ export function ToastContainer() {
export class Toast {
private readonly container: HTMLDivElement;
private readonly root: Root;
constructor(render?: (element: JSX.Element) => void) {
constructor(render?: (element: React.JSX.Element) => void) {
this.container = document.createElement('div');
document.body.append(this.container);
this.root = createRoot(this.container);
if (render) {
render(<ToastContainer/>);
} else {
ReactDOM.render(<ToastContainer/>, this.container);
this.root.render(<ToastContainer/>);
}
}
success(message: string) {
emitter.emit(TOAST_EVENT, {id: nanoid(4), type: 'success', message});
emitter.emit(ToastEvent, {id: nanoid(4), type: 'success', message});
}
info(message: string) {
emitter.emit(TOAST_EVENT, {id: nanoid(4), type: 'info', message});
emitter.emit(ToastEvent, {id: nanoid(4), type: 'info', message});
}
warning(message: string) {
emitter.emit(TOAST_EVENT, {id: nanoid(4), type: 'warning', message});
emitter.emit(ToastEvent, {id: nanoid(4), type: 'warning', message});
}
error(message: string) {
emitter.emit(TOAST_EVENT, {id: nanoid(4), type: 'error', message});
emitter.emit(ToastEvent, {id: nanoid(4), type: 'error', message});
}
clear() {
emitter.emit(CLEAR_EVENT);
emitter.emit(ClearEvent);
}
dispose() {
ReactDOM.unmountComponentAtNode(this.container);
this.root.unmount();
this.container.remove();
}
}

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: Record<string, 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,5 +1,10 @@
/* eslint-disable object-curly-newline */
import {fromEvent, merge, of, partition} from 'rxjs';
import {
fromEvent,
merge,
of,
partition,
} from 'rxjs';
import {filter, map, pairwise} from 'rxjs/operators';
export function registerNavbarPicker(
@ -9,9 +14,7 @@ export function registerNavbarPicker(
): void {
const color$ = fromEvent(picker, 'click').pipe(
map(event => event.target as HTMLElement),
filter(
(element): element is HTMLInputElement => element.tagName === 'INPUT',
),
filter((element): element is HTMLInputElement => element.tagName === 'INPUT'),
map(element => element.value),
);
@ -22,8 +25,7 @@ export function registerNavbarPicker(
});
const [light$, dark$] = partition(color$, color =>
['light', 'warning', 'white', 'orange', 'lime'].includes(color),
);
['light', 'warning', 'white', 'orange', 'lime'].includes(color));
light$.subscribe(() => {
// DO NOT use `classList.replace`.
navbar.classList.remove('navbar-dark');
@ -53,9 +55,7 @@ export function registerSidebarPicker(
fromEvent(light, 'click'),
).pipe(
map(event => event.target as HTMLElement),
filter(
(element): element is HTMLInputElement => element.tagName === 'INPUT',
),
filter((element): element is HTMLInputElement => element.tagName === 'INPUT'),
map(element => element.value),
);
@ -67,12 +67,8 @@ export function registerSidebarPicker(
}
const sidebar = document.querySelector<HTMLElement>('.main-sidebar');
const darkPicker = document.querySelector<HTMLDivElement>(
'#sidebar-dark-picker',
);
const lightPicker = document.querySelector<HTMLDivElement>(
'#sidebar-light-picker',
);
const darkPicker = document.querySelector<HTMLDivElement>('#sidebar-dark-picker');
const lightPicker = document.querySelector<HTMLDivElement>('#sidebar-light-picker');
if (sidebar && darkPicker && lightPicker) {
registerSidebarPicker(

View File

@ -1,5 +1,3 @@
import * as echarts from 'echarts/core';
import {SVGRenderer} from 'echarts/renderers';
import {LineChart} from 'echarts/charts';
import {
DataZoomComponent,
@ -7,6 +5,8 @@ import {
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import * as echarts from 'echarts/core';
import {SVGRenderer} from 'echarts/renderers';
import {get} from '../../scripts/net';
type ChartData = {
@ -31,12 +31,8 @@ echarts.use([
]);
async function main() {
const elementUsersRegistration = document.querySelector<HTMLDivElement>(
'#chart-users-registration',
);
const elementTexturesUpload = document.querySelector<HTMLDivElement>(
'#chart-textures-upload',
);
const elementUsersRegistration = document.querySelector<HTMLDivElement>('#chart-users-registration');
const elementTexturesUpload = document.querySelector<HTMLDivElement>('#chart-textures-upload');
if (!elementUsersRegistration || !elementTexturesUpload) {
return;
}

View File

@ -1,16 +1,16 @@
import clsx from 'clsx';
import {Box} from './styles';
import type {Player} from '@/scripts/types';
import {t} from '@/scripts/i18n';
import {showModal} from '@/scripts/notify';
import type {Player} from '@/scripts/types';
import clsx from 'clsx';
import {Box} from './styles';
type Properties = {
readonly player: Player;
onUpdateName(): void;
onUpdateOwner(): void;
onUpdateTexture(): void;
onDelete(): void;
onUpdateName: () => void;
onUpdateOwner: () => void;
onUpdateTexture: () => void;
onDelete: () => void;
};
const Card: React.FC<Properties> = properties => {
@ -31,7 +31,8 @@ const Card: React.FC<Properties> = properties => {
{player.tid_skin > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`}
target='_blank' rel='noreferrer'
target='_blank'
rel='noreferrer'
>
<picture>
<source srcSet={skinPreview} type='image/webp'/>
@ -48,7 +49,8 @@ const Card: React.FC<Properties> = properties => {
{player.tid_cape > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`}
target='_blank' rel='noreferrer'
target='_blank'
rel='noreferrer'
>
<picture>
<source srcSet={capePreview} type='image/webp'/>
@ -143,9 +145,14 @@ const Card: React.FC<Properties> = properties => {
</div>
<div>
<div>
<span className='mr-2'>PID: {player.pid}</span>
<span className='mr-2'>
PID:
{player.pid}
</span>
<span>
{t('general.player.owner')}: {player.uid}
{t('general.player.owner')}
:
{player.uid}
</span>
</div>
<div>

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import Skeleton from 'react-loading-skeleton';
import clsx from 'clsx';
import Skeleton from 'react-loading-skeleton';
import {Box} from './styles';
const isDarkMode = document.body.classList.contains('dark-mode');

View File

@ -1,12 +1,12 @@
import {useState} from 'react';
import Modal from '@/components/Modal';
import {t} from '@/scripts/i18n';
import {TextureType} from '@/scripts/types';
import Modal from '@/components/Modal';
import {useState} from 'react';
type Properties = {
readonly open: boolean;
onSubmit(type: 'skin' | 'cape', tid: number): void;
onClose(): void;
onSubmit: (type: 'skin' | 'cape', tid: number) => void;
onClose: () => void;
};
const ModalUpdateTexture: React.FC<Properties> = properties => {

View File

@ -1,14 +1,14 @@
import {t} from '@/scripts/i18n';
import type {Player} from '@/scripts/types';
import ButtonEdit from '@/components/ButtonEdit';
import {t} from '@/scripts/i18n';
type Properties = {
readonly player: Player;
onUpdateName(): void;
onUpdateOwner(): void;
onUpdateTexture(): void;
onDelete(): void;
onUpdateName: () => void;
onUpdateOwner: () => void;
onUpdateTexture: () => void;
onDelete: () => void;
};
const Row: React.FC<Properties> = properties => {
@ -40,7 +40,8 @@ const Row: React.FC<Properties> = properties => {
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`}
target='_blank'
className='mr-1' rel='noreferrer'
className='mr-1'
rel='noreferrer'
>
<img
src={`${blessing.base_url}/preview/${player.tid_skin}`}
@ -52,7 +53,8 @@ const Row: React.FC<Properties> = properties => {
{player.tid_cape > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`}
target='_blank' rel='noreferrer'
target='_blank'
rel='noreferrer'
>
<img
src={`${blessing.base_url}/preview/${player.tid_cape}`}

View File

@ -1,18 +1,18 @@
import {useState, useEffect, useLayoutEffect} from 'react';
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 Row from './Row';
import LoadingRow from './LoadingRow';
import ModalUpdateTexture from './ModalUpdateTexture';
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 Row from './Row';
function PlayersManagement() {
const [players, setPlayers] = useImmer<Player[]>([]);
@ -152,9 +152,7 @@ function PlayersManagement() {
return;
}
const {code, message} = await fetch.del<fetch.ResponseBody>(
urls.admin.players.delete(player.pid),
);
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);
@ -208,26 +206,47 @@ function PlayersManagement() {
</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>
{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) => <LoadingRow key={i}/>)
? Array.from({length: 10}).fill(null).map((_, i) => <LoadingCard key={i}/>)
: players.map((player, i) => (
<Row
<Card
key={player.pid}
player={player}
onUpdateName={async () => handleUpdateName(player, i)}
@ -238,27 +257,8 @@ function PlayersManagement() {
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>
)}
<div className='card-footer'>
<div className='float-right'>
<Pagination page={page} totalPages={totalPages} onChange={setPage}/>

View File

@ -1,5 +1,5 @@
import styled from '@emotion/styled';
import * as breakpoints from '@/styles/breakpoints';
import styled from '@emotion/styled';
export const Box = styled.div`
width: 48%;

View File

@ -1,8 +1,8 @@
import styled from '@emotion/styled';
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;
@ -41,9 +41,9 @@ const Description = styled.div`
type Properties = {
readonly plugin: Plugin;
onEnable(plugin: Plugin): void;
onDisable(plugin: Plugin): void;
onDelete(plugin: Plugin): void;
onEnable: (plugin: Plugin) => void;
onDisable: (plugin: Plugin) => void;
onDelete: (plugin: Plugin) => void;
readonly baseUrl: string;
};
@ -89,7 +89,8 @@ const InfoBox: React.FC<Properties> = properties => {
{plugin.title}
</strong>
<span className='d-none d-sm-inline-block text-gray'>
v{plugin.version}
v
{plugin.version}
</span>
</Header>
<div>

View File

@ -1,12 +1,12 @@
import {useState, useEffect} from 'react';
import {useImmer} from 'use-immer';
import InfoBox from './InfoBox';
import type {Plugin} from './types';
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 {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';
function PluginsManagement() {
const [isLoading, setIsLoading] = useState(true);
@ -33,9 +33,9 @@ function PluginsManagement() {
message,
data: {reason} = {reason: []},
} = await fetch.post<
fetch.ResponseBody<{
reason: string[];
}>
fetch.ResponseBody<{
reason: string[];
}>
>('/admin/plugins/manage', {
action: 'enable',
name: plugin.name,
@ -52,9 +52,8 @@ function PluginsManagement() {
<div>
<p>{message}</p>
<ul>
{reason.map((t, i) => (
<li key={i}>{t}</li>
))}
{reason.map((t, i) =>
<li key={i}>{t}</li>)}
</ul>
</div>
),
@ -166,27 +165,25 @@ function PluginsManagement() {
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>
))
))}
{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'>

View File

@ -5,8 +5,8 @@ import {t} from '@/scripts/i18n';
type Properties = {
readonly plugin: Plugin;
readonly isInstalling: boolean;
onInstall(): void;
onUpdate(): void;
onInstall: () => void;
onUpdate: () => void;
};
const Row: React.FC<Properties> = properties => {
@ -27,63 +27,71 @@ const Row: React.FC<Properties> = properties => {
<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>
)}
{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={properties.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={properties.isInstalling || Boolean(plugin.installed)}
onClick={properties.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>
)}
{plugin.can_update
? (
<button
className='btn btn-success'
disabled={isInstalling}
onClick={properties.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={properties.isInstalling || Boolean(plugin.installed)}
onClick={properties.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>
);

View File

@ -1,13 +1,13 @@
import {useState, useEffect, useMemo} from 'react';
import {enableMapSet} from 'immer';
import {useImmer} from 'use-immer';
import type {Plugin} from './types';
import Row from './Row';
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 {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();
@ -21,10 +21,8 @@ export default function PluginsMarket() {
const searchedPlugins = useMemo(
() =>
plugins.filter(
plugin =>
plugin.name.includes(search) || plugin.title.includes(search),
),
plugins.filter(plugin =>
plugin.name.includes(search) || plugin.title.includes(search)),
[plugins, search],
);
@ -45,9 +43,7 @@ export default function PluginsMarket() {
setSearch(search);
setPage(1);
const searchedPlugins = plugins.filter(
plugin => plugin.name.includes(search) || plugin.title.includes(search),
);
const searchedPlugins = plugins.filter(plugin => plugin.name.includes(search) || plugin.title.includes(search));
setTotalPages(Math.ceil(searchedPlugins.length / 10));
};
@ -79,9 +75,8 @@ export default function PluginsMarket() {
<div>
<p>{message}</p>
<ul>
{data.reason.map((t, i) => (
<li key={i}>{t}</li>
))}
{data.reason.map((t, i) =>
<li key={i}>{t}</li>)}
</ul>
</div>
),
@ -122,39 +117,41 @@ export default function PluginsMarket() {
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>
))}
{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}/>

View File

@ -1,8 +1,8 @@
import type {Texture} from '@/scripts/types';
import {t} from '@/scripts/i18n';
import styled from '@emotion/styled';
import {type Report, Status} from './types';
import {t} from '@/scripts/i18n';
import type {Texture} from '@/scripts/types';
const Card = styled.div`
width: 240px;
@ -31,10 +31,10 @@ const Card = styled.div`
type Properties = {
readonly report: Report;
onClick(texture: Texture | undefined): void;
onBan(): void;
onDelete(): void;
onReject(): void;
onClick: (texture: Texture | undefined) => void;
onBan: () => void;
onDelete: () => void;
onReject: () => void;
};
const ImageBox: React.FC<Properties> = properties => {
@ -54,7 +54,10 @@ const ImageBox: React.FC<Properties> = properties => {
{': '}
</b>
<span className='mr-1'>{report.texture_uploader?.nickname}</span>
(UID: {report.uploader})
(UID:
{' '}
{report.uploader}
)
</div>
<div className='card-body'>
<picture>
@ -70,14 +73,15 @@ const ImageBox: React.FC<Properties> = properties => {
<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>
{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
@ -92,7 +96,8 @@ const ImageBox: React.FC<Properties> = properties => {
<a
href={`${blessing.base_url}/skinlib/show/${report.tid}`}
className='dropdown-item'
target='_blank' rel='noreferrer'
target='_blank'
rel='noreferrer'
>
<i className='fas fa-share-square mr-2'/>
{t('user.viewInSkinlib')}
@ -122,7 +127,10 @@ const ImageBox: React.FC<Properties> = properties => {
{': '}
</b>
<span className='mr-1'>{report.informer?.nickname}</span>
(UID: {report.reporter})
(UID:
{' '}
{report.reporter}
)
</div>
<details>
<summary className='text-truncate'>

View File

@ -1,14 +1,14 @@
import React, {useState, useEffect} from 'react';
import {useImmer} from 'use-immer';
import type {Report, Status} from './types';
import ImageBox from './ImageBox';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {type Paginator, type 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 {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(async () => import('@/components/Viewer'));
@ -104,26 +104,28 @@ function ReportsManagement() {
</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>
))}
{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

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import type {Line} from './types';
import {t} from '@/scripts/i18n';
import styled from '@emotion/styled';
const Group = styled.td`
width: 15%;
@ -14,8 +14,8 @@ const Operations = styled.td`
type Properties = {
readonly line: Line;
onEdit(line: Line): void;
onRemove(line: Line): void;
onEdit: (line: Line) => void;
onRemove: (line: Line) => void;
};
const Row: React.FC<Properties> = properties => {

View File

@ -1,13 +1,13 @@
import React, {useState, useEffect} from 'react';
import {useImmer} from 'use-immer';
import type {Paginator} from '@/scripts/types';
import type {Line} from './types';
import Row from './Row';
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 type {Paginator} from '@/scripts/types';
import Loading from '@/components/Loading';
import Pagination from '@/components/Pagination';
import React, {useEffect, useState} from 'react';
import {useImmer} from 'use-immer';
import Row from './Row';
function Translations() {
const [lines, setLines] = useImmer<Line[]>([]);
@ -85,28 +85,30 @@ function Translations() {
</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}
/>
))
))}
{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>

View File

@ -1,15 +1,13 @@
import {t} from '../../scripts/i18n';
import {post, type ResponseBody} from '../../scripts/net';
import {showModal} from '../../scripts/notify';
import {t} from '../../scripts/i18n';
export default async function handler(event: MouseEvent) {
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',
)}`;
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;

View File

@ -1,25 +1,25 @@
import type {User} from '@/scripts/types';
import {t} from '@/scripts/i18n';
import clsx from 'clsx';
import {Box, Icon, InfoTable} from './styles';
import {
canModifyPermission,
canModifyUser,
humanizePermission,
verificationStatusText,
canModifyUser,
canModifyPermission,
} from './utils';
import {t} from '@/scripts/i18n';
import type {User} from '@/scripts/types';
type Properties = {
readonly user: User;
readonly currentUser: User;
onEmailChange(): void;
onNicknameChange(): void;
onScoreChange(): void;
onPermissionChange(): void;
onVerificationToggle(): void;
onPasswordChange(): void;
onDelete(): void;
onEmailChange: () => void;
onNicknameChange: () => void;
onScoreChange: () => void;
onPermissionChange: () => void;
onVerificationToggle: () => void;
onPasswordChange: () => void;
onDelete: () => void;
};
const Card: React.FC<Properties> = properties => {
@ -124,7 +124,10 @@ const Card: React.FC<Properties> = properties => {
</div>
</div>
<div>
<div>UID: {user.uid}</div>
<div>
UID:
{user.uid}
</div>
<div>
{t('general.user.email')}
{': '}

View File

@ -1,5 +1,5 @@
import {Breakpoint, lessThan} from '@/styles/breakpoints';
import styled from '@emotion/styled';
import {lessThan, Breakpoint} from '@/styles/breakpoints';
const Header = styled.div`
display: flex;

View File

@ -1,8 +1,8 @@
import styled from '@emotion/styled';
import Skeleton from 'react-loading-skeleton';
import clsx from 'clsx';
import {Box, Icon, InfoTable} from './styles';
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: ${properties => properties.width};

View File

@ -1,24 +1,24 @@
import {
humanizePermission,
verificationStatusText,
canModifyUser,
canModifyPermission,
} from './utils';
import {t} from '@/scripts/i18n';
import type {User} from '@/scripts/types';
import ButtonEdit from '@/components/ButtonEdit';
import {t} from '@/scripts/i18n';
import {
canModifyPermission,
canModifyUser,
humanizePermission,
verificationStatusText,
} from './utils';
type Properties = {
readonly user: User;
readonly currentUser: User;
onEmailChange(): void;
onNicknameChange(): void;
onScoreChange(): void;
onPermissionChange(): void;
onVerificationToggle(): void;
onPasswordChange(): void;
onDelete(): void;
onEmailChange: () => void;
onNicknameChange: () => void;
onScoreChange: () => void;
onPermissionChange: () => void;
onVerificationToggle: () => void;
onPasswordChange: () => void;
onDelete: () => void;
};
const Row: React.FC<Properties> = properties => {
@ -82,11 +82,9 @@ const Row: React.FC<Properties> = properties => {
title={t('admin.toggleVerification')}
onClick={properties.onVerificationToggle}
>
{user.verified ? (
<i className='fas fa-toggle-on'/>
) : (
<i className='fas fa-toggle-off'/>
)}
{user.verified
? <i className='fas fa-toggle-on'/>
: <i className='fas fa-toggle-off'/>}
</a>
)}
</td>

View File

@ -1,19 +1,19 @@
import {useState, useEffect, useLayoutEffect} from 'react';
import {useImmer} from 'use-immer';
import Header from './Header';
import Card from './Card';
import LoadingCard from './LoadingCard';
import Row from './Row';
import LoadingRow from './LoadingRow';
import type {Props as ModalInputProperties} from '@/components/ModalInput';
import Pagination from '@/components/Pagination';
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useIsLargeScreen from '@/scripts/hooks/useIsLargeScreen';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {type User, UserPermission, type Paginator} from '@/scripts/types';
import {toast, showModal} from '@/scripts/notify';
import {showModal, toast} from '@/scripts/notify';
import {type Paginator, type User, UserPermission} from '@/scripts/types';
import urls from '@/scripts/urls';
import type {Props as ModalInputProperties} from '@/components/ModalInput';
import Pagination from '@/components/Pagination';
import {useEffect, useLayoutEffect, useState} from 'react';
import {useImmer} from 'use-immer';
import Card from './Card';
import Header from './Header';
import LoadingCard from './LoadingCard';
import LoadingRow from './LoadingRow';
import Row from './Row';
function UsersManagement() {
const [users, setUsers] = useImmer<User[]>([]);
@ -196,9 +196,7 @@ function UsersManagement() {
};
const handleVerificationToggle = async (user: User, index: number) => {
const {code, message} = await fetch.put<fetch.ResponseBody>(
urls.admin.users.verification(user.uid),
);
const {code, message} = await fetch.put<fetch.ResponseBody>(urls.admin.users.verification(user.uid));
if (code === 0) {
toast.success(message);
setUsers(users => {
@ -298,28 +296,52 @@ function UsersManagement() {
</label>
</div>
</Header>
{users.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>UID</th>
<th>{t('general.user.email')}</th>
<th>{t('general.user.nickname')}</th>
<th>{t('general.user.score')}</th>
<th>{t('admin.permission')}</th>
<th>{t('admin.verification')}</th>
<th>{t('general.user.register-at')}</th>
<th>{t('admin.operationsTitle')}</th>
</tr>
</thead>
<tbody>
{users.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>UID</th>
<th>{t('general.user.email')}</th>
<th>{t('general.user.nickname')}</th>
<th>{t('general.user.score')}</th>
<th>{t('admin.permission')}</th>
<th>{t('admin.verification')}</th>
<th>{t('general.user.register-at')}</th>
<th>{t('admin.operationsTitle')}</th>
</tr>
</thead>
<tbody>
{isLoading
? Array.from({length: 10}).fill(null).map((_, i) => <LoadingRow key={i}/>)
: users.map((user, i) => (
<Row
key={user.uid}
user={user}
currentUser={currentUser}
onEmailChange={async () => handleEmailChange(user, i)}
onNicknameChange={async () => handleNicknameChange(user, i)}
onScoreChange={async () => handleScoreChange(user, i)}
onPermissionChange={async () => handlePermissionChange(user, i)}
onVerificationToggle={async () =>
handleVerificationToggle(user, i)}
onPasswordChange={async () => handlePasswordChange(user)}
onDelete={async () => handleDelete(user)}
/>
))}
</tbody>
</table>
</div>
)
: (
<div className='card-body d-flex flex-wrap'>
{isLoading
? Array.from({length: 10}).fill(null).map((_, i) => <LoadingRow key={i}/>)
? Array.from({length: 10}).fill(null).map((_, i) => <LoadingCard key={i}/>)
: users.map((user, i) => (
<Row
<Card
key={user.uid}
user={user}
currentUser={currentUser}
@ -327,35 +349,13 @@ function UsersManagement() {
onNicknameChange={async () => handleNicknameChange(user, i)}
onScoreChange={async () => handleScoreChange(user, i)}
onPermissionChange={async () => handlePermissionChange(user, i)}
onVerificationToggle={async () =>
handleVerificationToggle(user, i)}
onVerificationToggle={async () => handleVerificationToggle(user, i)}
onPasswordChange={async () => handlePasswordChange(user)}
onDelete={async () => handleDelete(user)}
/>
))}
</tbody>
</table>
</div>
) : (
<div className='card-body d-flex flex-wrap'>
{isLoading
? Array.from({length: 10}).fill(null).map((_, i) => <LoadingCard key={i}/>)
: users.map((user, i) => (
<Card
key={user.uid}
user={user}
currentUser={currentUser}
onEmailChange={async () => handleEmailChange(user, i)}
onNicknameChange={async () => handleNicknameChange(user, i)}
onScoreChange={async () => handleScoreChange(user, i)}
onPermissionChange={async () => handlePermissionChange(user, i)}
onVerificationToggle={async () => handleVerificationToggle(user, i)}
onPasswordChange={async () => handlePasswordChange(user)}
onDelete={async () => handleDelete(user)}
/>
))}
</div>
))}
</div>
)}
<div className='card-footer'>
<div className='float-right'>
<Pagination page={page} totalPages={totalPages} onChange={setPage}/>

View File

@ -1,5 +1,5 @@
import styled from '@emotion/styled';
import * as breakpoints from '@/styles/breakpoints';
import styled from '@emotion/styled';
export const Box = styled.div`
width: 48%;
@ -14,7 +14,7 @@ export const Icon = styled.div<{py?: boolean}>`
width: 70px;
display: flex;
justify-content: center;
padding-top: ${properties => (properties.py ? '22px' : '0')};
padding-top: ${properties => properties.py ? '22px' : '0'};
`;
export const InfoTable = styled.div`

View File

@ -1,18 +1,18 @@
import {useState, useRef} from 'react';
import Alert from '@/components/Alert';
import Captcha from '@/components/Captcha';
import EmailSuggestion from '@/components/EmailSuggestion';
import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import urls from '@/scripts/urls';
import Alert from '@/components/Alert';
import Captcha from '@/components/Captcha';
import EmailSuggestion from '@/components/EmailSuggestion';
import {useRef, useState} from 'react';
export default function Forgot() {
const [email, setEmail] = useState('');
const [isSending, setIsSending] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const [warningMessage, setWarningMessage] = useState('');
const reference = useRef<Captcha | null>(null);
const reference = useRef<Captcha>(null);
useEmitMounted();
@ -57,14 +57,14 @@ export default function Forgot() {
{t('auth.forgot.login-link')}
</a>
<button className='btn btn-primary' type='submit' disabled={isSending}>
{isSending ? (
<>
<i className='fas fa-spinner fa-spin mr-1'/>
{t('auth.sending')}
</>
) : (
t('auth.send')
)}
{isSending
? (
<>
<i className='fas fa-spinner fa-spin mr-1'/>
{t('auth.sending')}
</>
)
: t('auth.send')}
</button>
</div>
</form>

View File

@ -1,13 +1,13 @@
import {useState, useRef, useEffect} from 'react';
import Alert from '@/components/Alert';
import Captcha from '@/components/Captcha';
import EmailSuggestion from '@/components/EmailSuggestion';
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {showModal} from '@/scripts/notify';
import urls from '@/scripts/urls';
import Alert from '@/components/Alert';
import Captcha from '@/components/Captcha';
import EmailSuggestion from '@/components/EmailSuggestion';
import {useEffect, useRef, useState} from 'react';
type SuccessfulResponse = {
code: 0;
@ -21,9 +21,7 @@ type FailedResponse = {
};
type Response = SuccessfulResponse | FailedResponse;
function isSuccessfulResponse(
response: Response,
): response is SuccessfulResponse {
function isSuccessfulResponse(response: Response): response is SuccessfulResponse {
return response.code === 0;
}
@ -34,7 +32,7 @@ export default function Login() {
const [hasTooManyFails, setHasTooManyFails] = useState(false);
const [isPending, setIsPending] = useState(false);
const [warningMessage, setWarningMessage] = useState('');
const reference = useRef<Captcha | null>(null);
const reference = useRef<Captcha>(null);
const recaptcha = useBlessingExtra<string>('recaptcha');
const invisibleRecaptcha = useBlessingExtra<boolean>('invisible');
@ -111,10 +109,8 @@ export default function Login() {
value={password}
onChange={handlePasswordChange}
/>
<div className='input-group-append'>
<div className='input-group-text'>
<i className='fas fa-lock'/>
</div>
<div className='input-group-text'>
<i className='fas fa-lock'/>
</div>
</div>
@ -126,7 +122,7 @@ export default function Login() {
<label>
<input
type='checkbox'
className='mr-1'
className='me-1'
checked={remember}
onChange={handleRememberChange}
/>
@ -140,14 +136,14 @@ export default function Login() {
type='submit'
disabled={isPending}
>
{isPending ? (
<>
<i className='fas fa-spinner fa-spin mr-1'/>
{t('auth.loggingIn')}
</>
) : (
t('auth.login')
)}
{isPending
? (
<>
<i className='fas fa-spinner fa-spin mr-1'/>
{t('auth.loggingIn')}
</>
)
: t('auth.login')}
</button>
</form>
);

View File

@ -1,13 +1,13 @@
import {useState, useRef} from 'react';
import Alert from '@/components/Alert';
import Captcha from '@/components/Captcha';
import EmailSuggestion from '@/components/EmailSuggestion';
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {toast} from '@/scripts/notify';
import urls from '@/scripts/urls';
import Alert from '@/components/Alert';
import Captcha from '@/components/Captcha';
import EmailSuggestion from '@/components/EmailSuggestion';
import {useRef, useState} from 'react';
export default function Registration() {
const [email, setEmail] = useState('');
@ -18,8 +18,8 @@ export default function Registration() {
const [isPending, setIsPending] = useState(false);
const [warningMessage, setWarningMessage] = useState('');
const requirePlayer = useBlessingExtra<boolean>('player');
const confirmationReference = useRef<HTMLInputElement | undefined>(null);
const captchaReference = useRef<Captcha | undefined>(null);
const confirmationReference = useRef<HTMLInputElement>(null);
const captchaReference = useRef<Captcha>(null);
useEmitMounted();
@ -27,9 +27,7 @@ export default function Registration() {
setPassword(event.target.value);
};
const handleConfirmationChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const handleConfirmationChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setConfirmation(event.target.value);
};
@ -37,9 +35,7 @@ export default function Registration() {
setNickName(event.target.value);
};
const handlePlayerNameChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const handlePlayerNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setPlayerName(event.target.value);
};
@ -57,8 +53,11 @@ export default function Registration() {
const {code, message} = await fetch.post<fetch.ResponseBody>(
urls.auth.register(),
{
email, password, captcha: await captchaReference.current!.execute(),
...(requirePlayer ? {player_name: playerName} : {nickname: nickName}),
email,
password,
captcha: await captchaReference.current!.execute(),
// eslint-disable-next-line ts/naming-convention
...requirePlayer ? {player_name: playerName} : {nickname: nickName},
},
);
if (code === 0) {
@ -121,39 +120,41 @@ export default function Registration() {
</div>
</div>
</div>
{requirePlayer ? (
<div className='input-group mb-3' title={t('auth.player-name-intro')}>
<input
required
type='text'
className='form-control'
placeholder={t('auth.player-name')}
value={playerName}
onChange={handlePlayerNameChange}
/>
<div className='input-group-append'>
<div className='input-group-text'>
<i className='fas fa-gamepad'/>
{requirePlayer
? (
<div className='input-group mb-3' title={t('auth.player-name-intro')}>
<input
required
type='text'
className='form-control'
placeholder={t('auth.player-name')}
value={playerName}
onChange={handlePlayerNameChange}
/>
<div className='input-group-append'>
<div className='input-group-text'>
<i className='fas fa-gamepad'/>
</div>
</div>
</div>
</div>
) : (
<div className='input-group mb-3' title={t('auth.nickname-intro')}>
<input
required
type='text'
className='form-control'
placeholder={t('auth.nickname')}
value={nickName}
onChange={handleNickNameChange}
/>
<div className='input-group-append'>
<div className='input-group-text'>
<i className='fas fa-gamepad'/>
)
: (
<div className='input-group mb-3' title={t('auth.nickname-intro')}>
<input
required
type='text'
className='form-control'
placeholder={t('auth.nickname')}
value={nickName}
onChange={handleNickNameChange}
/>
<div className='input-group-append'>
<div className='input-group-text'>
<i className='fas fa-gamepad'/>
</div>
</div>
</div>
</div>
)}
)}
<Captcha ref={captchaReference}/>
<Alert type='warning'>{warningMessage}</Alert>
@ -161,14 +162,14 @@ export default function Registration() {
<div className='d-flex justify-content-between align-items-center mb-3'>
<a href={`${blessing.base_url}/auth/login`}>{t('auth.login-link')}</a>
<button className='btn btn-primary' type='submit' disabled={isPending}>
{isPending ? (
<>
<i className='fas fa-spinner fa-spin mr-1'/>
{t('auth.registering')}
</>
) : (
t('auth.register')
)}
{isPending
? (
<>
<i className='fas fa-spinner fa-spin mr-1'/>
{t('auth.registering')}
</>
)
: t('auth.register')}
</button>
</div>
</form>

View File

@ -1,10 +1,10 @@
import {useState} from 'react';
import Alert from '@/components/Alert';
import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {toast} from '@/scripts/notify';
import urls from '@/scripts/urls';
import Alert from '@/components/Alert';
import {useState} from 'react';
export default function Reset() {
const [password, setPassword] = useState('');
@ -18,9 +18,7 @@ export default function Reset() {
setPassword(event.target.value);
};
const handleConfirmationChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const handleConfirmationChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setConfirmation(event.target.value);
};
@ -94,14 +92,14 @@ export default function Reset() {
type='submit'
disabled={isPending}
>
{isPending ? (
<>
<i className='fas fa-spinner fa-spin mr-1'/>
{t('auth.resetting')}
</>
) : (
t('auth.reset')
)}
{isPending
? (
<>
<i className='fas fa-spinner fa-spin mr-1'/>
{t('auth.resetting')}
</>
)
: t('auth.reset')}
</button>
</form>
);

View File

@ -1,12 +1,10 @@
import type {Texture} from '@/scripts/types';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify';
import type {Texture} from '@/scripts/types';
import urls from '@/scripts/urls';
export default async function addClosetItem(
texture: Pick<Texture, 'tid' | 'name'>,
): Promise<boolean> {
export default async function addClosetItem(texture: Pick<Texture, 'tid' | 'name'>): Promise<boolean> {
let name: string;
try {
const {value} = await showModal({

View File

@ -1,7 +1,5 @@
import React, {useState, useEffect} from 'react';
import {createPortal} from 'react-dom';
import Skeleton from 'react-loading-skeleton';
import addClosetItem from './addClosetItem';
import ButtonEdit from '@/components/ButtonEdit';
import ViewerSkeleton from '@/components/ViewerSkeleton';
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import useMount from '@/scripts/hooks/useMount';
@ -10,11 +8,13 @@ import * as fetch from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify';
import {type Texture, TextureType} from '@/scripts/types';
import urls from '@/scripts/urls';
import ButtonEdit from '@/components/ButtonEdit';
import ViewerSkeleton from '@/components/ViewerSkeleton';
import ModalApply from '@/views/user/Closet/ModalApply';
import removeClosetItem from '@/views/user/Closet/removeClosetItem';
import setAsAvatar from '@/views/user/Closet/setAsAvatar';
import React, {useEffect, useState} from 'react';
import {createPortal} from 'react-dom';
import Skeleton from 'react-loading-skeleton';
import addClosetItem from './addClosetItem';
export type Badge = {
color: string;
@ -191,9 +191,7 @@ export default function Show() {
type ErrorResp = {code: 1; message: string};
type DuplicatedResp = {code: 2; message: string; data: {tid: number}};
const resp = await fetch.put<OkResp | ErrorResp | DuplicatedResp>(
urls.texture.privacy(texture.tid),
);
const resp = await fetch.put<OkResp | ErrorResp | DuplicatedResp>(urls.texture.privacy(texture.tid));
const {code, message} = resp;
if (code === 0) {
toast.success(message);
@ -224,9 +222,7 @@ export default function Show() {
return;
}
const {code, message} = await fetch.del<fetch.ResponseBody>(
urls.texture.delete(texture.tid),
);
const {code, message} = await fetch.del<fetch.ResponseBody>(urls.texture.delete(texture.tid));
if (code === 0) {
toast.success(message);
setTimeout(() => {
@ -274,80 +270,84 @@ export default function Show() {
isAlex={texture.type === TextureType.Alex}
initPositionZ={60}
>
{currentUid === 0 ? (
<button
disabled
type='button'
className='btn btn-outline-secondary'
title={t('skinlib.show.anonymous')}
>
{t('skinlib.addToCloset')}
</button>
) : (
<div className='d-flex justify-content-between align-items-center'>
<div>
{liked && (
<button
type='button'
className='btn btn-outline-success mr-2'
onClick={handleOpenModalApply}
>
{t('skinlib.apply')}
</button>
)}
{liked ? (
<button
type='button'
className='btn btn-outline-primary mr-2'
onClick={handleRemoveItemClick}
>
{t('skinlib.removeFromCloset')}
</button>
) : (
<button
type='button'
className='btn btn-outline-primary mr-2'
onClick={handleAddItemClick}
>
{t('skinlib.addToCloset')}
</button>
)}
{texture.type !== TextureType.Cape && (
<button
type='button'
className='btn btn-outline-info mr-2'
onClick={handleSetAsAvatar}
>
{t('user.setAsAvatar')}
</button>
)}
{canBeDownloaded && (
<a
role='button'
className='btn btn-outline-info mr-2'
href={`${blessing.base_url}/raw/${texture.tid}`}
download={`${texture.name}.png`}
>
{t('skinlib.show.download')}
</a>
)}
<button
type='button'
className='btn btn-outline-info mr-2'
onClick={handleReport}
>
{t('skinlib.report.title')}
</button>
</div>
<div
className={liked ? 'text-red' : 'text-gray'}
title={t('skinlib.show.likes')}
{currentUid === 0
? (
<button
disabled
type='button'
className='btn btn-outline-secondary'
title={t('skinlib.show.anonymous')}
>
<i className='fas fa-heart mr-1'/>
<span>{texture.likes}</span>
{t('skinlib.addToCloset')}
</button>
)
: (
<div className='d-flex justify-content-between align-items-center'>
<div>
{liked && (
<button
type='button'
className='btn btn-outline-success mr-2'
onClick={handleOpenModalApply}
>
{t('skinlib.apply')}
</button>
)}
{liked
? (
<button
type='button'
className='btn btn-outline-primary mr-2'
onClick={handleRemoveItemClick}
>
{t('skinlib.removeFromCloset')}
</button>
)
: (
<button
type='button'
className='btn btn-outline-primary mr-2'
onClick={handleAddItemClick}
>
{t('skinlib.addToCloset')}
</button>
)}
{texture.type !== TextureType.Cape && (
<button
type='button'
className='btn btn-outline-info mr-2'
onClick={handleSetAsAvatar}
>
{t('user.setAsAvatar')}
</button>
)}
{canBeDownloaded && (
<a
role='button'
className='btn btn-outline-info mr-2'
href={`${blessing.base_url}/raw/${texture.tid}`}
download={`${texture.name}.png`}
>
{t('skinlib.show.download')}
</a>
)}
<button
type='button'
className='btn btn-outline-info mr-2'
onClick={handleReport}
>
{t('skinlib.report.title')}
</button>
</div>
<div
className={liked ? 'text-red' : 'text-gray'}
title={t('skinlib.show.likes')}
>
<i className='fas fa-heart mr-1'/>
<span>{texture.likes}</span>
</div>
</div>
</div>
)}
)}
</Previewer>
</React.Suspense>,
container,
@ -360,49 +360,53 @@ export default function Show() {
<div className='container'>
<div className='row mt-2 mb-4'>
<div className='col-4'>{t('skinlib.show.name')}</div>
{isLoading ? (
<div className='col-8'>
<Skeleton/>
</div>
) : (
<>
<div className='col-7 text-truncate' title={texture.name}>
{texture.name}
{isLoading
? (
<div className='col-8'>
<Skeleton/>
</div>
{canEdit && (
<div className='col-1'>
<ButtonEdit
title={t('skinlib.show.edit')}
onClick={handleEditName}
/>
)
: (
<>
<div className='col-7 text-truncate' title={texture.name}>
{texture.name}
</div>
)}
</>
)}
{canEdit && (
<div className='col-1'>
<ButtonEdit
title={t('skinlib.show.edit')}
onClick={handleEditName}
/>
</div>
)}
</>
)}
</div>
<div className='row my-4'>
<div className='col-4'>{t('skinlib.show.model')}</div>
{isLoading ? (
<div className='col-8'>
<Skeleton/>
</div>
) : (
<>
<div className='col-7'>
{texture.type === TextureType.Cape
? t('general.cape')
: texture.type}
{isLoading
? (
<div className='col-8'>
<Skeleton/>
</div>
{canEdit && (
<div className='col-1'>
<ButtonEdit
title={t('skinlib.show.edit')}
onClick={handleSwitchType}
/>
)
: (
<>
<div className='col-7'>
{texture.type === TextureType.Cape
? t('general.cape')
: texture.type}
</div>
)}
</>
)}
{canEdit && (
<div className='col-1'>
<ButtonEdit
title={t('skinlib.show.edit')}
onClick={handleSwitchType}
/>
</div>
)}
</>
)}
</div>
<div className='row my-4'>
<div className='col-4'>Hash</div>
@ -416,35 +420,41 @@ export default function Show() {
<div className='row my-4'>
<div className='col-4'>{t('skinlib.show.size')}</div>
<div className='col-8'>
{isLoading ? <Skeleton/> : <span>{texture.size} KB</span>}
{isLoading
? <Skeleton/>
: <span>
{texture.size}
{' '}
KB
</span>}
</div>
</div>
<div className='row my-4'>
<div className='col-4'>{t('skinlib.show.uploader')}</div>
<div className='col-8 text-truncate'>
{isLoading ? (
<Skeleton/>
) : (isUploaderExists ? (
<>
<div>
<a href={linkToUploader} target='_blank' rel='noreferrer'>
{nickname}
</a>
</div>
<div>
{badges.map(badge => (
<span
key={badge.text}
className={`badge bg-${badge.color} mr-2`}
>
{badge.text}
</span>
))}
</div>
</>
) : (
nickname
))}
{isLoading
? <Skeleton/>
: isUploaderExists
? (
<>
<div>
<a href={linkToUploader} target='_blank' rel='noreferrer'>
{nickname}
</a>
</div>
<div>
{badges.map(badge => (
<span
key={badge.text}
className={`badge bg-${badge.color} mr-2`}
>
{badge.text}
</span>
))}
</div>
</>
)
: nickname}
</div>
</div>
<div className='row mt-4 mb-2'>

View File

@ -5,8 +5,8 @@ type Properties = {
};
type Attributes = React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
const Button: React.FC<Properties & Attributes> = properties => {

View File

@ -1,13 +1,13 @@
import Button from './Button';
import type {Filter} from './types';
import {humanizeType} from './utils';
import {TextureType} from '@/scripts/types';
import {t} from '@/scripts/i18n';
import {TextureType} from '@/scripts/types';
import Button from './Button';
import {humanizeType} from './utils';
type Properties = {
readonly filter: Filter;
onChange(filter: Filter): void;
onChange: (filter: Filter) => void;
};
const FilterSelector: React.FC<Properties> = properties => {

View File

@ -1,9 +1,9 @@
import styled from '@emotion/styled';
import type {LibraryItem} from './types';
import {humanizeType} from './utils';
import {t} from '@/scripts/i18n';
import * as cssUtils from '@/styles/utils';
import styled from '@emotion/styled';
import {humanizeType} from './utils';
const Card = styled.div`
width: 245px;
@ -42,9 +42,9 @@ const ButtonLike = styled.a<ButtonLikeProperties>`
${cssUtils.pointerCursor}
i, span {
color: ${properties => (properties.liked ? '#dc3545' : '#6c757d')};
color: ${properties => properties.liked ? '#dc3545' : '#6c757d'};
&:hover {
color: ${properties => (properties.liked ? '#dc3545' : '#343a40')};
color: ${properties => properties.liked ? '#dc3545' : '#343a40'};
}
}
`;
@ -52,9 +52,9 @@ const ButtonLike = styled.a<ButtonLikeProperties>`
type Properties = {
readonly item: LibraryItem;
readonly liked: boolean;
onAdd(texture: LibraryItem): Promise<void>;
onRemove(texture: LibraryItem): Promise<void>;
onUploaderClick(uploader: number): void;
onAdd: (texture: LibraryItem) => Promise<void>;
onRemove: (texture: LibraryItem) => Promise<void>;
onUploaderClick: (uploader: number) => void;
};
const Item: React.FC<Properties> = properties => {

View File

@ -1,9 +1,6 @@
import {useState, useEffect} from 'react';
import addClosetItem from '../Show/addClosetItem';
import FilterSelector from './FilterSelector';
import Button from './Button';
import Item from './Item';
import type {Filter, LibraryItem} from './types';
import Loading from '@/components/Loading';
import Pagination from '@/components/Pagination';
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import {t} from '@/scripts/i18n';
@ -11,9 +8,12 @@ import * as fetch from '@/scripts/net';
import {toast} from '@/scripts/notify';
import {type Paginator, TextureType} from '@/scripts/types';
import urls from '@/scripts/urls';
import Loading from '@/components/Loading';
import Pagination from '@/components/Pagination';
import removeClosetItem from '@/views/user/Closet/removeClosetItem';
import {useEffect, useState} from 'react';
import addClosetItem from '../Show/addClosetItem';
import Button from './Button';
import FilterSelector from './FilterSelector';
import Item from './Item';
function SkinLibrary() {
const [isLoading, setIsLoading] = useState(true);
@ -35,16 +35,14 @@ function SkinLibrary() {
const search = new URLSearchParams(query);
const filter = search.get('filter') ?? '';
setFilter(
[
'skin',
TextureType.Steve,
TextureType.Alex,
TextureType.Cape,
].includes(filter)
? (filter as Filter)
: 'skin',
);
setFilter([
'skin',
TextureType.Steve,
TextureType.Alex,
TextureType.Cape,
].includes(filter)
? (filter as Filter)
: 'skin');
const keyword = decodeURIComponent(search.get('keyword') ?? '');
setName(keyword);
@ -187,17 +185,19 @@ function SkinLibrary() {
<div className='container-fluid d-flex justify-content-between'>
<h1>{t('general.skinlib')}</h1>
<span>
{uploader ? (
<>
<i className='fas fa-user mr-1'/>
{t('skinlib.filter.uploader', {uid: uploader.toString()})}
</>
) : (
<>
<i className='fas fa-user-friends mr-1'/>
{t('skinlib.filter.allUsers')}
</>
)}
{uploader
? (
<>
<i className='fas fa-user mr-1'/>
{t('skinlib.filter.uploader', {uid: uploader.toString()})}
</>
)
: (
<>
<i className='fas fa-user-friends mr-1'/>
{t('skinlib.filter.allUsers')}
</>
)}
</span>
</div>
</div>
@ -263,22 +263,22 @@ function SkinLibrary() {
</div>
</div>
</div>
{items.length > 0 ? (
<div className='d-flex flex-wrap'>
{items.map((item, i) => (
<Item
key={item.tid}
item={item}
liked={closet.includes(item.tid)}
onAdd={async item => handleAddToCloset(item, i)}
onRemove={async item => handleRemoveFromCloset(item, i)}
onUploaderClick={handleUploaderClick}
/>
))}
</div>
) : (
<p className='text-center m-5'>{t('general.noResult')}</p>
)}
{items.length > 0
? (
<div className='d-flex flex-wrap'>
{items.map((item, i) => (
<Item
key={item.tid}
item={item}
liked={closet.includes(item.tid)}
onAdd={async item => handleAddToCloset(item, i)}
onRemove={async item => handleRemoveFromCloset(item, i)}
onUploaderClick={handleUploaderClick}
/>
))}
</div>
)
: <p className='text-center m-5'>{t('general.noResult')}</p>}
</div>
<div className='card-footer'>
<div className='d-flex justify-content-center'>

View File

@ -1,16 +1,16 @@
import React, {useState} from 'react';
import ReactDOM from 'react-dom';
import {t} from '@/scripts/i18n';
import FileInput from '@/components/FileInput';
import ViewerSkeleton from '@/components/ViewerSkeleton';
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import useMount from '@/scripts/hooks/useMount';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify';
import {isAlex} from '@/scripts/textureUtils';
import {TextureType} from '@/scripts/types';
import urls from '@/scripts/urls';
import FileInput from '@/components/FileInput';
import ViewerSkeleton from '@/components/ViewerSkeleton';
import React, {useState} from 'react';
import ReactDOM from 'react-dom';
const Previewer = React.lazy(async () => import('@/components/Viewer'));
@ -46,9 +46,7 @@ function Upload() {
setIsPrivate(event.target.checked);
};
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files!;
const [file] = files;
if (file) {
@ -60,7 +58,7 @@ function Upload() {
const texture = URL.createObjectURL(file);
setTexture(texture);
if (type !== TextureType.Cape) {
setType((await isAlex(texture)) ? TextureType.Alex : TextureType.Steve);
setType(await isAlex(texture) ? TextureType.Alex : TextureType.Steve);
}
}
};
@ -208,14 +206,14 @@ function Upload() {
disabled={isUploading}
onClick={handleUpload}
>
{isUploading ? (
<>
<i className='fas fa-spinner fa-spin mr-1'/>
<span>{t('skinlib.uploading')}</span>
</>
) : (
t('skinlib.upload.button')
)}
{isUploading
? (
<>
<i className='fas fa-spinner fa-spin mr-1'/>
<span>{t('skinlib.uploading')}</span>
</>
)
: t('skinlib.upload.button')}
</button>
</div>
{file && (
@ -231,9 +229,8 @@ function Upload() {
</div>
</div>
)}
{isPrivate && (
<div className='callout callout-info mt-3'>{privacyNotice}</div>
)}
{isPrivate
&& <div className='callout callout-info mt-3'>{privacyNotice}</div>}
{!isPrivate && award > 0 && (
<div className='callout callout-success mt-3'>
{t('skinlib.upload.award', {score: award.toString()})}
@ -242,16 +239,16 @@ function Upload() {
</div>
</div>
{container
&& ReactDOM.createPortal(
<React.Suspense fallback={<ViewerSkeleton/>}>
<Previewer
skin={type === TextureType.Cape ? undefined : texture}
cape={type === TextureType.Cape ? texture : undefined}
isAlex={type === TextureType.Alex}
/>
</React.Suspense>,
container,
)}
&& ReactDOM.createPortal(
<React.Suspense fallback={<ViewerSkeleton/>}>
<Previewer
skin={type === TextureType.Cape ? undefined : texture}
cape={type === TextureType.Cape ? texture : undefined}
isAlex={type === TextureType.Alex}
/>
</React.Suspense>,
container,
)}
</>
);
}

View File

@ -1,15 +1,15 @@
import type {ClosetItem as ClosetItemType} from '@/scripts/types';
import {t} from '@/scripts/i18n';
import setAsAvatar from './setAsAvatar';
import {Card, DropdownButton} from './styles';
import {t} from '@/scripts/i18n';
import type {ClosetItem as ClosetItemType} from '@/scripts/types';
type Properties = {
readonly item: ClosetItemType;
readonly selected: boolean;
onClick(item: ClosetItemType): void;
onRename(): void;
onRemove(): void;
onClick: (item: ClosetItemType) => void;
onRename: () => void;
onRemove: () => void;
};
const ClosetItem: React.FC<Properties> = properties => {
@ -58,7 +58,8 @@ const ClosetItem: React.FC<Properties> = properties => {
<a
href={`${blessing.base_url}/skinlib/show/${item.tid}`}
className='dropdown-item'
target='_blank' rel='noreferrer'
target='_blank'
rel='noreferrer'
>
{t('user.viewInSkinlib')}
</a>

View File

@ -1,12 +1,12 @@
import {useState, useEffect} from 'react';
import $ from 'jquery';
import type {Player} from '@/scripts/types';
import Loading from '@/components/Loading';
import Modal from '@/components/Modal';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {toast} from '@/scripts/notify';
import type {Player} from '@/scripts/types';
import urls from '@/scripts/urls';
import Loading from '@/components/Loading';
import Modal from '@/components/Modal';
import $ from 'jquery';
import {useEffect, useState} from 'react';
const baseUrl = blessing.base_url;
@ -15,7 +15,7 @@ type Properties = {
readonly canAdd: boolean;
readonly skin?: number;
readonly cape?: number;
onClose(): void;
onClose: () => void;
};
const ModalApply: React.FC<Properties> = properties => {
@ -67,46 +67,46 @@ const ModalApply: React.FC<Properties> = properties => {
footer={<></>}
onClose={properties.onClose}
>
{isLoading ? (
<Loading/>
) : (players.length === 0 ? (
<p>{t('user.closet.use-as.empty')}</p>
) : (
<>
<div className='form-group'>
<input
type='text'
className='form-control'
placeholder={t('user.typeToSearch')}
onChange={handleSearch}
/>
</div>
{players
.filter(player => player.name.includes(search))
.map(player => (
<button
key={player.pid}
className='btn btn-block btn-outline-info text-left'
title={player.name}
onClick={async () => handleSelect(player)}
>
<picture>
<source
srcSet={`${baseUrl}/avatar/${player.tid_skin}?3d&size=45`}
type='image/webp'
/>
<img
src={`${baseUrl}/avatar/${player.tid_skin}?3d&png&size=45`}
alt={player.name}
width={45}
height={45}
/>
</picture>
<span className='ml-1'>{player.name}</span>
</button>
))}
</>
))}
{isLoading
? <Loading/>
: players.length === 0
? <p>{t('user.closet.use-as.empty')}</p>
: (
<>
<div className='form-group'>
<input
type='text'
className='form-control'
placeholder={t('user.typeToSearch')}
onChange={handleSearch}
/>
</div>
{players
.filter(player => player.name.includes(search))
.map(player => (
<button
key={player.pid}
className='btn btn-block btn-outline-info text-left'
title={player.name}
onClick={async () => handleSelect(player)}
>
<picture>
<source
srcSet={`${baseUrl}/avatar/${player.tid_skin}?3d&size=45`}
type='image/webp'
/>
<img
src={`${baseUrl}/avatar/${player.tid_skin}?3d&png&size=45`}
alt={player.name}
width={45}
height={45}
/>
</picture>
<span className='ml-1'>{player.name}</span>
</button>
))}
</>
)}
</Modal>
);
};

View File

@ -1,7 +1,7 @@
import ViewerSkeleton from '@/components/ViewerSkeleton';
import useMount from '@/scripts/hooks/useMount';
import React from 'react';
import ReactDOM from 'react-dom';
import useMount from '@/scripts/hooks/useMount';
import ViewerSkeleton from '@/components/ViewerSkeleton';
const Viewer = React.lazy(async () => import('@/components/Viewer'));
@ -20,14 +20,14 @@ const Previewer: React.FC<Properties> = properties => {
return (
container
&& ReactDOM.createPortal(
<React.Suspense fallback={<ViewerSkeleton/>}>
<Viewer showIndicator skin={skin} cape={cape} isAlex={properties.isAlex}>
{properties.children}
</Viewer>
</React.Suspense>,
container,
)
&& ReactDOM.createPortal(
<React.Suspense fallback={<ViewerSkeleton/>}>
<Viewer showIndicator skin={skin} cape={cape} isAlex={properties.isAlex}>
{properties.children}
</Viewer>
</React.Suspense>,
container,
)
);
};

View File

@ -1,22 +1,22 @@
import {useState, useEffect, useRef} from 'react';
import debounce from 'lodash-es/debounce';
import ClosetItem from './ClosetItem';
import LoadingClosetItem from './LoadingClosetItem';
import Previewer from './Previewer';
import ModalApply from './ModalApply';
import removeClosetItem from './removeClosetItem';
import Pagination from '@/components/Pagination';
import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify';
import {
type ClosetItem as Item,
type Texture,
type Paginator,
type Texture,
TextureType,
} from '@/scripts/types';
import urls from '@/scripts/urls';
import Pagination from '@/components/Pagination';
import debounce from 'lodash-es/debounce';
import {useEffect, useRef, useState} from 'react';
import ClosetItem from './ClosetItem';
import LoadingClosetItem from './LoadingClosetItem';
import ModalApply from './ModalApply';
import Previewer from './Previewer';
import removeClosetItem from './removeClosetItem';
type Category = 'skin' | 'cape';
@ -39,10 +39,10 @@ function Closet() {
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [items, setItems] = useState<Item[]>([]);
const [skin, setSkin] = useState<Texture | null>(null);
const [cape, setCape] = useState<Texture | null>(null);
const [skin, setSkin] = useState<Texture | undefined>(null);
const [cape, setCape] = useState<Texture | undefined>(null);
const [showModalApply, setShowModalApply] = useState(false);
const containerReference = useRef<HTMLDivElement | null>(null);
const containerReference = useRef<HTMLDivElement | undefined>(null);
const perPageReference = useRef(6);
useEmitMounted();
@ -64,7 +64,10 @@ function Closet() {
const {data, last_page: lastPage} = await fetch.get<Paginator<Item>>(
urls.user.closet.list(),
{
category, q: query, page: page.toString(), perPage: perPageReference.current.toString(),
category,
q: query,
page: page.toString(),
perPage: perPageReference.current.toString(),
},
);
@ -223,40 +226,43 @@ function Closet() {
</div>
</div>
<div className='card-body'>
{isLoading ? (
<div className='d-flex flex-wrap'>
{new Array(perPageReference.current).fill(null).map((_, i) => (
<LoadingClosetItem key={i}/>
))}
</div>
) : (items.length === 0 ? (
<div className='text-center p-3'>
{search ? (
t('general.noResult')
) : (
<span
dangerouslySetInnerHTML={{
__html: t('user.emptyClosetMsg', {
url: `${blessing.base_url}/skinlib?filter=${category}`,
}),
}}
/>
{isLoading
? (
<div className='d-flex flex-wrap'>
{new Array(perPageReference.current).fill(null).map((_, i) =>
<LoadingClosetItem key={i}/>)}
</div>
)
: items.length === 0
? (
<div className='text-center p-3'>
{search
? t('general.noResult')
: (
<span
dangerouslySetInnerHTML={{
__html: t('user.emptyClosetMsg', {
url: `${blessing.base_url}/skinlib?filter=${category}`,
}),
}}
/>
)}
</div>
)
: (
<div className='d-flex flex-wrap'>
{items.map((item, i) => (
<ClosetItem
key={item.tid}
item={item}
selected={isSelected(item)}
onClick={handleSelect}
onRename={async () => renameItem(item, i)}
onRemove={async () => removeItem(item)}
/>
))}
</div>
)}
</div>
) : (
<div className='d-flex flex-wrap'>
{items.map((item, i) => (
<ClosetItem
key={item.tid}
item={item}
selected={isSelected(item)}
onClick={handleSelect}
onRename={async () => renameItem(item, i)}
onRemove={async () => removeItem(item)}
/>
))}
</div>
))}
</div>
<div className='card-footer'>
<div className='float-right'>

View File

@ -13,9 +13,7 @@ export default async function removeClosetItem(tid: number): Promise<boolean> {
return false;
}
const {code, message} = await fetch.del<fetch.ResponseBody>(
urls.user.closet.remove(tid),
);
const {code, message} = await fetch.del<fetch.ResponseBody>(urls.user.closet.remove(tid));
if (code === 0) {
toast.success(message);
} else {

View File

@ -21,7 +21,12 @@ const InfoBox: React.FC<Properties> = properties => {
<div className='info-box-content'>
<span className='info-box-text'>{properties.name}</span>
<span className='info-box-number'>
<b>{properties.used}</b> / {total} {properties.unit}
<b>{properties.used}</b>
{' '}
/
{total}
{' '}
{properties.unit}
</span>
<div className='progress'>
<div className='progress-bar' style={{width: `${percentage}%`}}/>

View File

@ -1,6 +1,6 @@
import {t} from '@/scripts/i18n';
import React from 'react';
import * as scoreUtils from './scoreUtils';
import {t} from '@/scripts/i18n';
type Properties = {
readonly isLoading: boolean;
@ -27,7 +27,9 @@ const SignButton: React.FC<Properties> = properties => {
disabled={!canSign || properties.isLoading}
onClick={properties.onClick}
>
<i className='far fa-calendar-check' aria-hidden='true'/> &nbsp;
<i className='far fa-calendar-check' aria-hidden='true'/>
{' '}
&nbsp;
{canSign ? t('user.sign') : remainingTimeText}
</button>
);

View File

@ -1,15 +1,15 @@
import {useState, useEffect, useCallback} from 'react';
import styled from '@emotion/styled';
import InfoBox from './InfoBox';
import SignButton from './SignButton';
import * as scoreUtils from './scoreUtils';
import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import useTween from '@/scripts/hooks/useTween';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {toast} from '@/scripts/notify';
import useTween from '@/scripts/hooks/useTween';
import urls from '@/scripts/urls';
import * as breakpoints from '@/styles/breakpoints';
import styled from '@emotion/styled';
import {useCallback, useEffect, useState} from 'react';
import InfoBox from './InfoBox';
import * as scoreUtils from './scoreUtils';
import SignButton from './SignButton';
type ScoreInfo = {
signAfterZero: boolean;
@ -77,7 +77,7 @@ export default function Dashboard() {
const handleSign = useCallback(async () => {
setLoading(true);
const {code, message, data} = await fetch.post<
fetch.ResponseBody<SignReturn>
fetch.ResponseBody<SignReturn>
>(urls.user.sign());
if (code === 0) {
@ -116,25 +116,27 @@ export default function Dashboard() {
unused={score / playersRate}
unit=''
/>
{storage > 1024 ? (
<InfoBox
color='maroon'
icon='hdd'
name={t('user.used.storage')}
used={Math.trunc(storage / 1024)}
unused={Math.trunc(score / storageRate / 1024)}
unit='MB'
/>
) : (
<InfoBox
color='maroon'
icon='hdd'
name={t('user.used.storage')}
used={storage}
unused={score / storageRate}
unit='KB'
/>
)}
{storage > 1024
? (
<InfoBox
color='maroon'
icon='hdd'
name={t('user.used.storage')}
used={Math.trunc(storage / 1024)}
unused={Math.trunc(score / storageRate / 1024)}
unit='MB'
/>
)
: (
<InfoBox
color='maroon'
icon='hdd'
name={t('user.used.storage')}
used={storage}
unused={score / storageRate}
unit='KB'
/>
)}
</div>
<div className='col-md-4 text-center'>
<ScoreTitle>{t('user.cur-score')}</ScoreTitle>

View File

@ -24,11 +24,11 @@ export function remainingTimeText(remainingTime: number): string {
const time = remainingTime / ONE_MINUTE;
return time < 60
? t('user.signRemainingTime', {
time: (Math.trunc(time)).toString(),
time: Math.trunc(time).toString(),
unit: t('user.timeUnitMin'),
})
: t('user.signRemainingTime', {
time: (Math.trunc(time / 60)).toString(),
time: Math.trunc(time / 60).toString(),
unit: t('user.timeUnitHour'),
});
}

View File

@ -1,11 +1,11 @@
import {useState} from 'react';
import {t} from '@/scripts/i18n';
import Modal from '@/components/Modal';
import {t} from '@/scripts/i18n';
import {useState} from 'react';
type Properties = {
readonly show: boolean;
onCreate(name: string, redirect: string): Promise<void>;
onClose(): void;
onCreate: (name: string, redirect: string) => Promise<void>;
onClose: () => void;
};
const ModalCreate: React.FC<Properties> = properties => {

View File

@ -1,7 +1,7 @@
import type {App} from './types';
import {t} from '@/scripts/i18n';
import ButtonEdit from '@/components/ButtonEdit';
import {t} from '@/scripts/i18n';
type Properties = {
readonly app: App;

View File

@ -1,11 +1,11 @@
import React, {useState, useEffect} from 'react';
import Row from './Row';
import ModalCreate from './ModalCreate';
import type {App} from './types';
import Loading from '@/components/Loading';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify';
import Loading from '@/components/Loading';
import React, {useEffect, useState} from 'react';
import ModalCreate from './ModalCreate';
import Row from './Row';
type Exception = {
message: string;
@ -133,14 +133,15 @@ function OAuth() {
</tr>
</thead>
<tbody>
{apps.length === 0 ? (
<tr>
<td className='text-center' colSpan={5}>
{isLoading ? <Loading/> : t('general.noResult')}
</td>
</tr>
) : (
apps.map((app, i) => (
{apps.length === 0
? (
<tr>
<td className='text-center' colSpan={5}>
{isLoading ? <Loading/> : t('general.noResult')}
</td>
</tr>
)
: apps.map((app, i) => (
<Row
key={app.id}
app={app}
@ -148,8 +149,7 @@ function OAuth() {
onEditRedirect={async () => editRedirect(app, i)}
onDelete={async () => handleDelete(app)}
/>
))
)}
))}
</tbody>
</table>
</div>

View File

@ -1,10 +1,10 @@
import {useState} from 'react';
import type {Player} from '@/scripts/types';
import Modal from '@/components/Modal';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {toast} from '@/scripts/notify';
import type {Player} from '@/scripts/types';
import urls from '@/scripts/urls';
import Modal from '@/components/Modal';
import {useState} from 'react';
type Extra = {
score: number;
@ -15,8 +15,8 @@ type Extra = {
type Properties = {
readonly show: boolean;
onAdd(player: Player): void;
onClose(): void;
onAdd: (player: Player) => void;
onClose: () => void;
};
const ModalAddPlayer: React.FC<Properties> = properties => {
@ -81,7 +81,9 @@ const ModalAddPlayer: React.FC<Properties> = properties => {
>
<i className={`icon fas fa-${isScoreEnough ? 'check' : 'times'}`}/>
<span className='ml-1'>
{t('user.cur-score')} {score}
{t('user.cur-score')}
{' '}
{score}
</span>
</div>
</Modal>

View File

@ -1,11 +1,11 @@
import {useState} from 'react';
import {t} from '@/scripts/i18n';
import Modal from '@/components/Modal';
import {t} from '@/scripts/i18n';
import {useState} from 'react';
type Properties = {
readonly show: boolean;
onSubmit(skin: boolean, cape: boolean): Promise<void>;
onClose(): void;
onSubmit: (skin: boolean, cape: boolean) => Promise<void>;
onClose: () => void;
};
const ModalReset: React.FC<Properties> = properties => {

View File

@ -1,9 +1,9 @@
import ViewerSkeleton from '@/components/ViewerSkeleton';
import useMount from '@/scripts/hooks/useMount';
import {t} from '@/scripts/i18n';
import React, {useState} from 'react';
import ReactDOM from 'react-dom';
import Viewer2d from './Viewer2d';
import useMount from '@/scripts/hooks/useMount';
import {t} from '@/scripts/i18n';
import ViewerSkeleton from '@/components/ViewerSkeleton';
const Viewer3d = React.lazy(async () => import('@/components/Viewer'));
@ -32,20 +32,22 @@ const Previewer: React.FC<Properties> = properties => {
return (
container
&& ReactDOM.createPortal(
is3d ? (
<React.Suspense fallback={<ViewerSkeleton/>}>
<Viewer3d skin={skin} cape={cape} isAlex={isAlex}>
{switcher}
</Viewer3d>
</React.Suspense>
) : (
<Viewer2d skin={skin} cape={cape}>
{switcher}
</Viewer2d>
),
container,
)
&& ReactDOM.createPortal(
is3d
? (
<React.Suspense fallback={<ViewerSkeleton/>}>
<Viewer3d skin={skin} cape={cape} isAlex={isAlex}>
{switcher}
</Viewer3d>
</React.Suspense>
)
: (
<Viewer2d skin={skin} cape={cape}>
{switcher}
</Viewer2d>
),
container,
)
);
};

View File

@ -1,17 +1,16 @@
/** @jsxImportSource @emotion/react */
import {css} from '@emotion/react';
import {t} from '@/scripts/i18n';
import type {Player} from '@/scripts/types';
import ButtonEdit from '@/components/ButtonEdit';
import {t} from '@/scripts/i18n';
import * as cssUtils from '@/styles/utils';
import {css} from '@emotion/react';
type Properties = {
player: Player;
selected: boolean;
onClick: React.MouseEventHandler;
onEditName(player: Player): Promise<void>;
onReset(): void;
onDelete(player: Player): Promise<void>;
onEditName: (player: Player) => Promise<void>;
onReset: () => void;
onDelete: (player: Player) => Promise<void>;
};
const Row: React.FC<Properties> = properties => {
@ -27,7 +26,7 @@ const Row: React.FC<Properties> = properties => {
const selected
= properties.selected
&& css`
&& css`
background: #efefef;
.dark-mode & {
background: var(--dark);

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import {t} from '@/scripts/i18n';
import styled from '@emotion/styled';
const TexturePreview = styled.div`
display: flex;
@ -27,19 +27,15 @@ const Viewer2d: React.FC<Properties> = properties => (
<div className='card-body'>
<TexturePreview className='mb-5'>
<span>{t('general.skin')}</span>
{properties.skin ? (
<img src={properties.skin} alt={t('general.skin')}/>
) : (
<span>{t('user.player.texture-empty')}</span>
)}
{properties.skin
? <img src={properties.skin} alt={t('general.skin')}/>
: <span>{t('user.player.texture-empty')}</span>}
</TexturePreview>
<TexturePreview className='mt-5'>
<span>{t('general.cape')}</span>
{properties.cape ? (
<img src={properties.cape} alt={t('general.cape')}/>
) : (
<span>{t('user.player.texture-empty')}</span>
)}
{properties.cape
? <img src={properties.cape} alt={t('general.cape')}/>
: <span>{t('user.player.texture-empty')}</span>}
</TexturePreview>
</div>
<div className='card-footer'>{properties.children}</div>

View File

@ -1,9 +1,3 @@
import {useState, useEffect} from 'react';
import Row from './Row';
import LoadingRow from './LoadingRow';
import Previewer from './Previewer';
import ModalAddPlayer from './ModalAddPlayer';
import ModalReset from './ModalReset';
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import useTexture from '@/scripts/hooks/useTexture';
@ -12,6 +6,12 @@ import * as fetch from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify';
import {type Player, TextureType} from '@/scripts/types';
import urls from '@/scripts/urls';
import {useEffect, useState} from 'react';
import LoadingRow from './LoadingRow';
import ModalAddPlayer from './ModalAddPlayer';
import ModalReset from './ModalReset';
import Previewer from './Previewer';
import Row from './Row';
function Players() {
const [players, setPlayers] = useState<Player[]>([]);
@ -104,9 +104,7 @@ function Players() {
search.append('cape', 'true');
}
const {code, message} = await fetch.del<fetch.ResponseBody>(
`${urls.user.player.clear(selected)}?${search.toString()}`,
);
const {code, message} = await fetch.del<fetch.ResponseBody>(`${urls.user.player.clear(selected)}?${search.toString()}`);
if (code === 0) {
toast.success(message);
if (skin) {
@ -147,9 +145,7 @@ function Players() {
return;
}
const {code, message} = await fetch.del<fetch.ResponseBody>(
urls.user.player.delete(player.pid),
);
const {code, message} = await fetch.del<fetch.ResponseBody>(urls.user.player.delete(player.pid));
if (code === 0) {
toast.success(message);
const {pid} = player;
@ -196,33 +192,33 @@ function Players() {
</tr>
</thead>
<tbody>
{isLoading ? (
new Array(playersCount)
{isLoading
? new Array(playersCount)
.fill(null)
.map((_, i) => <LoadingRow key={i}/>)
) : (players.length === 0 ? (
<tr>
<td className='text-center' colSpan={3}>
{t('general.noResult')}
</td>
</tr>
) : (
players
.filter(({name}) => name.includes(search))
.map((player, i) => (
<Row
key={player.pid}
player={player}
selected={selected === player.pid}
onClick={() => {
selectPlayer(player);
}}
onEditName={async () => editName(player, i)}
onReset={openModalReset}
onDelete={deletePlayer}
/>
))
))}
: players.length === 0
? (
<tr>
<td className='text-center' colSpan={3}>
{t('general.noResult')}
</td>
</tr>
)
: players
.filter(({name}) => name.includes(search))
.map((player, i) => (
<Row
key={player.pid}
player={player}
selected={selected === player.pid}
onClick={() => {
selectPlayer(player);
}}
onEditName={async () => editName(player, i)}
onReset={openModalReset}
onDelete={deletePlayer}
/>
))}
</tbody>
</table>
</div>

View File

@ -1,8 +1,8 @@
import resetAvatar from './resetAvatar';
import passwordFormHandler from './password';
import nicknameFormHandler from './nickname';
import emailFormHandler from './email';
import deleteAccountFormHandler from './deleteAccount';
import emailFormHandler from './email';
import nicknameFormHandler from './nickname';
import passwordFormHandler from './password';
import resetAvatar from './resetAvatar';
const buttonResetAvatar = document.querySelector('#reset-avatar');
buttonResetAvatar?.addEventListener('click', resetAvatar);
@ -16,7 +16,5 @@ nicknameForm?.addEventListener('submit', nicknameFormHandler);
const emailForm = document.querySelector<HTMLFormElement>('#change-email');
emailForm?.addEventListener('submit', emailFormHandler);
const deleteAccountForm = document.querySelector<HTMLFormElement>(
'#modal-delete-account',
);
const deleteAccountForm = document.querySelector<HTMLFormElement>('#modal-delete-account');
deleteAccountForm?.addEventListener('submit', deleteAccountFormHandler);

View File

@ -1,5 +1,5 @@
import {post, type ResponseBody} from '@/scripts/net';
import {t} from '@/scripts/i18n';
import {post, type ResponseBody} from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify';
export default async function handler(event: Event) {

View File

@ -1,6 +1,6 @@
import {showModal, toast} from '@/scripts/notify';
import {t} from '@/scripts/i18n';
import {post, type ResponseBody} from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify';
export default async function resetAvatar() {
try {

View File

@ -1,16 +1,14 @@
import React, {useState} from 'react';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {toast} from '@/scripts/notify';
import React, {useState} from 'react';
function EmailVerification() {
const [isSending, setIsSending] = useState(false);
const send = async () => {
setIsSending(true);
const {code, message} = await fetch.post<fetch.ResponseBody>(
'/user/email-verification',
);
const {code, message} = await fetch.post<fetch.ResponseBody>('/user/email-verification');
if (code === 0) {
toast.success(message);
} else {
@ -23,20 +21,24 @@ function EmailVerification() {
return (
<div className='callout callout-info'>
<h4>
<i className='fas fa-envelope'/> {t('user.verification.title')}
<i className='fas fa-envelope'/>
{' '}
{t('user.verification.title')}
</h4>
<p>
{t('user.verification.message')}
{isSending ? (
<>
<i className='fas fa-spin fa-spinner mr-1'/>
{t('user.verification.sending')}
</>
) : (
<a className='link-info' href='#' onClick={send}>
{t('user.verification.resend')}
</a>
)}
{isSending
? (
<>
<i className='fas fa-spin fa-spinner mr-1'/>
{t('user.verification.sending')}
</>
)
: (
<a className='link-info' href='#' onClick={send}>
{t('user.verification.resend')}
</a>
)}
</p>
</div>
);

View File

@ -1,6 +1,6 @@
import React, {useState, useEffect} from 'react';
import * as fetch from '@/scripts/net';
import {showModal} from '@/scripts/notify';
import React, {useEffect, useState} from 'react';
export type Notification = {
id: string;
@ -12,9 +12,7 @@ function NotificationsList() {
const [noUnreadText, setNoUnreadText] = useState('');
useEffect(() => {
const dataset = document.querySelector<HTMLLIElement>(
'[data-notifications]',
)?.dataset;
const dataset = document.querySelector<HTMLLIElement>('[data-notifications]')?.dataset;
if (dataset) {
const notifications: Notification[] = JSON.parse(dataset.notifications!);
setNotifications(notifications);
@ -41,8 +39,7 @@ function NotificationsList() {
),
});
setNotifications(notifications =>
notifications.filter(notification => notification.id !== id),
);
notifications.filter(notification => notification.id !== id));
};
const hasUnread = notifications.length > 0;
@ -58,8 +55,8 @@ function NotificationsList() {
)}
</a>
<div className='dropdown-menu dropdown-menu-lg dropdown-menu-right'>
{hasUnread ? (
notifications.map(notification => (
{hasUnread
? notifications.map(notification => (
<>
<a
key={notification.id}
@ -73,9 +70,7 @@ function NotificationsList() {
<div className='dropdown-divider'/>
</>
))
) : (
<p className='text-center text-muted pt-2 pb-2'>{noUnreadText}</p>
)}
: <p className='text-center text-muted pt-2 pb-2'>{noUnreadText}</p>}
</div>
</>
);

2
resources/assets/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/* eslint-disable ts/triple-slash-reference */
/// <reference types="vite/client" />

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