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

View File

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

72
.vscode/launch.json vendored
View File

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

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", "name": "blessing-skin-server",
"type": "module",
"version": "6.0.2", "version": "6.0.2",
"private": true, "private": true,
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"description": "A web application brings your custom skins back in offline Minecraft servers.", "description": "A web application brings your custom skins back in offline Minecraft servers.",
"author": "printempw",
"license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/bs-community/blessing-skin-server" "url": "https://github.com/bs-community/blessing-skin-server"
}, },
"license": "MIT",
"author": "printempw",
"type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"build:urls": "ts-node tools/generateUrls.ts", "build:urls": "ts-node tools/generateUrls.ts",
@ -22,109 +23,64 @@
"iOS >= 12.5", "iOS >= 12.5",
"Chrome >= 87" "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": { "dependencies": {
"@emotion/react": "^11.11.4", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.0.0", "@emotion/styled": "^11.14.0",
"@fortawesome/fontawesome-free": "^6.3.0", "@fortawesome/fontawesome-free": "^6.7.2",
"@tweenjs/tween.js": "^23.1.1", "@tweenjs/tween.js": "^25.0.0",
"admin-lte": "next", "admin-lte": "4.0.0-beta3",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"clsx": "^2.1.0", "clsx": "^2.1.1",
"echarts": "^5.5.0", "downshift": "^9.0.8",
"immer": "^10.0.3", "echarts": "^5.6.0",
"immer": "^10.1.1",
"jquery": "^3.6.0", "jquery": "^3.6.0",
"lodash-es": "^4.0.8", "lodash-es": "^4.0.8",
"nanoid": "^5.0.6", "nanoid": "^5.0.9",
"prompts": "^2.4.0", "prompts": "^2.4.0",
"react": "^18.2.0", "react": "^18.0.0",
"react-autosuggest": "^10.0.2", "react-dom": "^18.0.0",
"react-dom": "^18.2.0",
"react-draggable": "^4.4.2", "react-draggable": "^4.4.2",
"react-loading-skeleton": "^3.4.0", "react-loading-skeleton": "^3.5.0",
"react-use": "^17.5.0", "react-use": "^17.6.0",
"reaptcha": "^1.7.2", "reaptcha": "^1.7.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"skinview-utils": "^0.7.1", "skinview-utils": "^0.7.1",
"skinview3d": "^3.0.0-alpha.1", "skinview3d": "^3.1.0",
"spectre.css": "npm:@angular-package/spectre.css", "spectre.css": "github:angular-package/spectre.css",
"use-immer": "^0.9.0" "use-immer": "^0.11.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.4.2", "@eslint-react/eslint-plugin": "^1.23.2",
"@testing-library/react": "^14.2.1", "@mochaa/eslintrc": "^0.1.12",
"@tsconfig/vite-react": "^3.0.0", "@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/bootstrap": "^5.2.10",
"@types/jquery": "^3.5.13", "@types/jquery": "^3.5.32",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.0.6", "@types/lodash-es": "^4.0.6",
"@types/prompts": "^2.0.9", "@types/prompts": "^2.0.9",
"@types/react": "^18.2.62", "@types/react": "^18",
"@types/react-autosuggest": "^10.1.11", "@types/react-dom": "^18",
"@types/react-dom": "^18.2.19",
"@types/tween.js": "^18.5.0", "@types/tween.js": "^18.5.0",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.7.2",
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.20",
"browserslist": "^4.23.0", "browserslist": "^4.24.4",
"browserslist-to-esbuild": "^2.1.1", "browserslist-to-esbuild": "^2.1.1",
"eslint-config-xo-react": "^0.27.0", "eslint": "^9.18.0",
"eslint-plugin-react": "^7.34.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.18",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"laravel-vite-plugin": "^1.0.2", "laravel-vite-plugin": "^1.1.1",
"postcss": "^8.4.35", "postcss": "^8.5.1",
"sass": "^1.71.1", "sass": "^1.83.4",
"typescript": "^5.3.3", "typescript": "^5.7.3",
"vite": "^5.1.5", "vite": "^6.0.7",
"vite-plugin-top-level-await": "^1.4.1", "vitest": "^3.0.2"
"vite-plugin-wasm": "^3.3.0", },
"vitest": "^1.3.1", "resolutions": {
"xo": "^0.57.0" "kleur": "^4.1.3"
}, },
"postcss": { "postcss": {
"plugins": { "plugins": {

1
public/.gitignore vendored
View File

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

View File

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

View File

@ -1,11 +1,10 @@
type Properties = { type Properties = {
readonly title?: string; readonly title?: string;
readonly onClick: React.MouseEventHandler<HTMLAnchorElement>; readonly onClick: React.MouseEventHandler<HTMLAnchorElement>;
}; };
const ButtonEdit: React.FC<Properties> = properties => ( const ButtonEdit: React.FC<Properties> = ({title, onClick}) => (
<a href='#' title={properties.title} className='ml-2' onClick={properties.onClick}> <a href='#' title={title} className='ml-2' onClick={onClick}>
<i className='fas fa-edit'/> <i className='fas fa-edit'/>
</a> </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 {emit, on} from '@/scripts/event';
import {t} from '@/scripts/i18n'; import {t} from '@/scripts/i18n';
import * as cssUtils from '@/styles/utils'; import * as cssUtils from '@/styles/utils';
import React from 'react';
import Reaptcha from 'reaptcha';
const eventId = Symbol(); const eventId = Symbol('EventId');
type State = { type State = {
value: string; value: string;
@ -16,20 +15,22 @@ type State = {
class Captcha extends React.Component<Record<string, unknown>, State> { class Captcha extends React.Component<Record<string, unknown>, State> {
state: State; state: State;
ref: React.MutableRefObject<Reaptcha | undefined>; // eslint-disable-next-line ts/no-restricted-types
ref: React.RefObject<Reaptcha | null>;
constructor(properties: Record<string, unknown>) { constructor(properties: Record<string, unknown>) {
super(properties); super(properties);
this.state = { this.state = {
value: '', value: '',
time: Date.now(), time: Date.now(),
sitekey: blessing.extra.recaptcha, sitekey: blessing.extra.recaptcha as string,
invisible: blessing.extra.invisible, invisible: blessing.extra.invisible as boolean,
}; };
this.ref = React.createRef(); this.ref = React.createRef<Reaptcha>();
} }
execute = async () => { // eslint-disable-next-line react/no-unused-class-component-members
async execute() {
const recaptcha = this.ref.current; const recaptcha = this.ref.current;
if (recaptcha && this.state.invisible) { if (recaptcha && this.state.invisible) {
return new Promise<string>(resolve => { return new Promise<string>(resolve => {
@ -37,21 +38,22 @@ class Captcha extends React.Component<Record<string, unknown>, State> {
resolve(value); resolve(value);
off(); off();
}); });
recaptcha.execute(); void recaptcha.execute();
}); });
} }
return this.state.value; return this.state.value;
}; }
reset = () => { // eslint-disable-next-line react/no-unused-class-component-members
reset() {
const recaptcha = this.ref.current; const recaptcha = this.ref.current;
if (recaptcha) { if (recaptcha) {
recaptcha.reset(); void recaptcha.reset();
} else { } else {
this.setState({time: Date.now()}); this.setState({time: Date.now()});
} }
}; }
handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => { handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({value: event.target.value}); this.setState({value: event.target.value});
@ -67,37 +69,39 @@ class Captcha extends React.Component<Record<string, unknown>, State> {
}; };
render() { render() {
return this.state.sitekey ? ( return this.state.sitekey
<div className='mb-2'> ? (
<Reaptcha <div className='mb-2'>
ref={this.ref} <Reaptcha
sitekey={this.state.sitekey} ref={this.ref}
size={this.state.invisible ? 'invisible' : 'normal'} sitekey={this.state.sitekey}
onVerify={this.handleVerify} size={this.state.invisible ? 'invisible' : 'normal'}
/> onVerify={this.handleVerify}
</div>
) : (
<div className='d-flex'>
<div className='form-group mb-3 mr-2'>
<input
required
type='text'
className='form-control'
placeholder={t('auth.captcha')}
value={this.state.value}
onChange={this.handleValueChange}
/> />
</div> </div>
<img )
src={`${blessing.base_url}/auth/captcha?v=${this.state.time}`} : (
alt={t('auth.captcha')} <div className='d-flex'>
css={cssUtils.pointerCursor} <div className='form-group mb-3 mr-2'>
height={34} <input
title={t('auth.change-captcha')} required
onClick={this.handleRefresh} type='text'
/> className='form-control'
</div> 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 * as fetch from '@/scripts/net';
import {useState} from 'react';
type Properties = { type Properties = {
readonly initMode: boolean; 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 {emit} from '@/scripts/event';
import {pointerCursor} from '@/styles/utils'; 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` const styles = css`
.dropdown-menu li { .dropdown-menu li {
@ -14,75 +13,53 @@ const styles = css`
const domainNames = new Set(['qq.com', '163.com', 'gmail.com', 'hotmail.com']); const domainNames = new Set(['qq.com', '163.com', 'gmail.com', 'hotmail.com']);
type Properties = Omit<Autosuggest.InputProps<string>, 'onChange'> & { type Properties = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
onChange(value: string): void; onChange: (value: string) => void;
}; };
const EmailSuggestion: React.FC<Properties> = properties => { const EmailSuggestion: React.FC<Properties> = props => {
const [suggestions, setSuggestions] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
emit('emailDomainsSuggestion', domainNames); emit('emailDomainsSuggestion', domainNames);
}, []); }, []);
const [inputItems, setInputItems] = useState<string[]>([]);
const handleSuggestionsFetchRequested: Autosuggest.SuggestionsFetchRequested const {
= ({value}) => { isOpen,
const segments = value.split('@'); getLabelProps,
setSuggestions([...domainNames].map(name => `${segments[0]}@${name}`)); 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 = () => { const {onChange} = props;
setSuggestions([]); onChange(value);
}; },
});
const shouldRenderSuggestions = (value: string) => { return (
const isSelecting = [...domainNames].some(name => <div>
value.endsWith(`@${name}`), <div className='input-group'>
); <input className='form-control' {...{...props, onChange: undefined}} {...getInputProps()}/>
<div className='input-group-text' {...getLabelProps()}>
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'>
<i className='fas fa-envelope'/> <i className='fas fa-envelope'/>
</div> </div>
</div> </div>
</div> <div className='mb-3 dropdown' css={styles}>
); <ul className={clsx('dropdown-menu', isOpen && inputItems.length > 0 && 'show')} {...getMenuProps()}>
{isOpen && inputItems.length > 0 && inputItems.map((item, index) => (
return ( <li key={`${item}`} className={clsx('dropdown-item', {active: index === highlightedIndex})} {...getItemProps({item, index})}>
<div css={styles}> {item}
<Autosuggest </li>
suggestions={suggestions} ))}
getSuggestionValue={getSuggestionValue} </ul>
renderSuggestion={renderSuggestion} </div>
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> </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 {t} from '@/scripts/i18n';
import {css} from '@emotion/react';
import {useRef} from 'react';
const hideRawBrowseButton = css` const hideRawBrowseButton = css`
::after { ::after {
@ -12,7 +11,7 @@ const hideRawBrowseButton = css`
type Properties = { type Properties = {
file: File | undefined; file: File | undefined;
accept?: string; accept?: string;
onChange(event: React.ChangeEvent<HTMLInputElement>): void; onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}; };
const FileInput: React.FC<Properties> = properties => { const FileInput: React.FC<Properties> = properties => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,11 @@
/** @jsxImportSource @emotion/react */ import {t} from '@/scripts/i18n';
import * as breakpoints from '@/styles/breakpoints';
import {useState, useEffect, useRef} from 'react'; import * as cssUtils from '@/styles/utils';
import {useMeasure} from 'react-use';
import {css} from '@emotion/react'; import {css} from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import {useEffect, useRef, useState} from 'react';
import {useMeasure} from 'react-use';
import * as skinview3d from 'skinview3d'; import * as skinview3d from 'skinview3d';
import SkinSteve from '../../../misc/textures/steve.png';
import bg1 from '../../../misc/backgrounds/1.webp'; import bg1 from '../../../misc/backgrounds/1.webp';
import bg2 from '../../../misc/backgrounds/2.webp'; import bg2 from '../../../misc/backgrounds/2.webp';
import bg3 from '../../../misc/backgrounds/3.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 bg5 from '../../../misc/backgrounds/5.webp';
import bg6 from '../../../misc/backgrounds/6.webp'; import bg6 from '../../../misc/backgrounds/6.webp';
import bg7 from '../../../misc/backgrounds/7.webp'; import bg7 from '../../../misc/backgrounds/7.webp';
import * as breakpoints from '@/styles/breakpoints'; import SkinSteve from '../../../misc/textures/steve.png';
import * as cssUtils from '@/styles/utils';
import {t} from '@/scripts/i18n';
const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7]; const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7];
export const PICTURES_COUNT = backgrounds.length; export const PICTURES_COUNT = backgrounds.length;
@ -227,9 +225,8 @@ const Viewer: React.FC<Properties> = properties => {
<div className='d-flex justify-content-between'> <div className='d-flex justify-content-between'>
<h3 className='card-title'> <h3 className='card-title'>
<span>{t('general.texturePreview')}</span> <span>{t('general.texturePreview')}</span>
{properties.showIndicator && ( {properties.showIndicator
<span className='badge bg-olive ml-1'>{indicator}</span> && <span className='badge bg-olive ml-1'>{indicator}</span>}
)}
</h3> </h3>
<div> <div>
<ActionButton <ActionButton
@ -238,14 +235,14 @@ const Viewer: React.FC<Properties> = properties => {
data-placement='bottom' data-placement='bottom'
title={t('general.switchCapeElytra')} title={t('general.switchCapeElytra')}
onClick={toggleBackEquippment} onClick={toggleBackEquippment}
/> />
<ActionButton <ActionButton
className='fas fa-person-running' className='fas fa-person-running'
data-toggle='tooltip' data-toggle='tooltip'
data-placement='bottom' data-placement='bottom'
title={t('general.switchAnimation')} title={t('general.switchAnimation')}
onClick={toggleAnimation} onClick={toggleAnimation}
/> />
<ActionButton <ActionButton
className={`fas fa-${paused ? 'play' : 'pause'}`} className={`fas fa-${paused ? 'play' : 'pause'}`}
data-toggle='tooltip' data-toggle='tooltip'
@ -256,14 +253,14 @@ const Viewer: React.FC<Properties> = properties => {
: t('general.pauseAnimation') : t('general.pauseAnimation')
} }
onClick={togglePause} onClick={togglePause}
/> />
<ActionButton <ActionButton
className='fas fa-rotate-right' className='fas fa-rotate-right'
data-toggle='tooltip' data-toggle='tooltip'
data-placement='bottom' data-placement='bottom'
title={t('general.rotation')} title={t('general.rotation')}
onClick={toggleRotate} onClick={toggleRotate}
/> />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,26 +1,23 @@
import $ from 'jquery';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {createRoot} from 'react-dom/client'; 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 routes from './scripts/route';
import './scripts/app';
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
Object.assign(window, {React, ReactDOM, $}); Object.assign(window, {React, ReactDOM, $});
const route = routes.find(route => const route = routes.find(route =>
new RegExp(`^${route.path}$`, 'i').test(blessing.route), new RegExp(`^${route.path}$`, 'i').test(blessing.route));
);
if (route) { if (route) {
if (route.module) { if (route.module) {
void Promise.all(route.module.map(async m => m())); void Promise.all(route.module.map(async m => m()));
} }
if (route.react) { if (route.react) {
const Component = React.lazy( const Component = React.lazy(route.react as () => Promise<{default: React.ComponentType}>);
route.react as () => Promise<{default: React.ComponentType}>,
);
const container = typeof route.el === 'string' const container = typeof route.el === 'string'
? document.querySelector(route.el) ? document.querySelector(route.el)
@ -28,11 +25,11 @@ if (route) {
const root = createRoot(container!); const root = createRoot(container!);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<React.Suspense fallback={route.frame?.() ?? ''}> <React.Suspense fallback={route.frame?.() ?? ''}>
<Component/> <Component/>
</React.Suspense> </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 'admin-lte';
import './extra'; import './extra';
import './i18n'; import './i18n';
@ -10,5 +11,5 @@ import './logout';
import './darkMode'; import './darkMode';
window.addEventListener('load', () => { window.addEventListener('load', () => {
$('[data-toggle="tooltip"]').tooltip(); [...document.querySelectorAll('[data-toggle="tooltip"]')].map(el => new Tooltip(el));
}); });

View File

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

View File

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

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: (...args: any[]) => void) {
export function on(event: string | symbol, listener: CallableFunction) {
if (!bus.has(event)) { if (!bus.has(event)) {
bus.set(event, new Set()); 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) { export function emit(event: string | symbol, payload?: unknown) {
bus.get(event)?.forEach(listener => listener(payload)); bus.get(event)?.forEach(listener => {
listener(payload);
});
} }
blessing.event = {on, emit}; blessing.event = {on, emit};

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import {useState, useEffect} from 'react'; import {useEffect, useState} from 'react';
import * as fetch from '../net'; import * as fetch from '../net';
import {type Texture, TextureType} from '../types'; 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 TWEEN from '@tweenjs/tween.js';
import {useEffect, useRef, useState} from 'react';
export default function useTween<T = any>(initialValue: T) { export default function useTween<T = any>(initialValue: T) {
const [value, setValue] = useState<T>(initialValue); const [value, setValue] = useState<T>(initialValue);

View File

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

View File

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

View File

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

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'; import Modal, {type ModalOptions, type ModalResult} from '../components/Modal';
export async function showModal(options: ModalOptions = {}): Promise<ModalResult> { export async function showModal(options: ModalOptions = {}): Promise<ModalResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const container = document.createElement('div'); const container = document.createElement('div');
document.body.append(container); document.body.append(container);
const root = createRoot(container);
const handleClose = () => { const handleClose = () => {
ReactDOM.unmountComponentAtNode(container); root.unmount();
container.remove(); container.remove();
}; };
ReactDOM.render( root.render((
<Modal <Modal
{...options} {...options}
show show
@ -19,8 +20,7 @@ export async function showModal(options: ModalOptions = {}): Promise<ModalResult
onConfirm={resolve} onConfirm={resolve}
onDismiss={reject} onDismiss={reject}
onClose={handleClose} onClose={handleClose}
/>, />
container, ));
);
}); });
} }

View File

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

View File

@ -1,7 +1,7 @@
import ReactDOM from 'react-dom';
import NotificationsList from '@/views/widgets/NotificationsList'; import NotificationsList from '@/views/widgets/NotificationsList';
import {createRoot} from 'react-dom/client';
const container = document.querySelector('[data-notifications]'); const container = document.querySelector('[data-notifications]');
if (container) { 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(); export const toast = new Toast();
if (process.env.NODE_ENV === 'test') { if (import.meta.env.NODE_ENV === 'test') {
afterEach(() => { afterEach(() => {
toast.clear(); toast.clear();
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import Skeleton from 'react-loading-skeleton';
import clsx from 'clsx'; import clsx from 'clsx';
import Skeleton from 'react-loading-skeleton';
import {Box} from './styles'; import {Box} from './styles';
const isDarkMode = document.body.classList.contains('dark-mode'); 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 {t} from '@/scripts/i18n';
import {TextureType} from '@/scripts/types'; import {TextureType} from '@/scripts/types';
import Modal from '@/components/Modal'; import {useState} from 'react';
type Properties = { type Properties = {
readonly open: boolean; readonly open: boolean;
onSubmit(type: 'skin' | 'cape', tid: number): void; onSubmit: (type: 'skin' | 'cape', tid: number) => void;
onClose(): void; onClose: () => void;
}; };
const ModalUpdateTexture: React.FC<Properties> = properties => { const ModalUpdateTexture: React.FC<Properties> = properties => {

View File

@ -1,14 +1,14 @@
import {t} from '@/scripts/i18n';
import type {Player} from '@/scripts/types'; import type {Player} from '@/scripts/types';
import ButtonEdit from '@/components/ButtonEdit'; import ButtonEdit from '@/components/ButtonEdit';
import {t} from '@/scripts/i18n';
type Properties = { type Properties = {
readonly player: Player; readonly player: Player;
onUpdateName(): void; onUpdateName: () => void;
onUpdateOwner(): void; onUpdateOwner: () => void;
onUpdateTexture(): void; onUpdateTexture: () => void;
onDelete(): void; onDelete: () => void;
}; };
const Row: React.FC<Properties> = properties => { const Row: React.FC<Properties> = properties => {
@ -40,7 +40,8 @@ const Row: React.FC<Properties> = properties => {
<a <a
href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`} href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`}
target='_blank' target='_blank'
className='mr-1' rel='noreferrer' className='mr-1'
rel='noreferrer'
> >
<img <img
src={`${blessing.base_url}/preview/${player.tid_skin}`} src={`${blessing.base_url}/preview/${player.tid_skin}`}
@ -52,7 +53,8 @@ const Row: React.FC<Properties> = properties => {
{player.tid_cape > 0 && ( {player.tid_cape > 0 && (
<a <a
href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`} href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`}
target='_blank' rel='noreferrer' target='_blank'
rel='noreferrer'
> >
<img <img
src={`${blessing.base_url}/preview/${player.tid_cape}`} 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 {useImmer} from 'use-immer';
import Header from '../UsersManagement/Header'; import Header from '../UsersManagement/Header';
import Card from './Card'; import Card from './Card';
import LoadingCard from './LoadingCard'; import LoadingCard from './LoadingCard';
import Row from './Row';
import LoadingRow from './LoadingRow'; import LoadingRow from './LoadingRow';
import ModalUpdateTexture from './ModalUpdateTexture'; import ModalUpdateTexture from './ModalUpdateTexture';
import useIsLargeScreen from '@/scripts/hooks/useIsLargeScreen'; import Row from './Row';
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';
function PlayersManagement() { function PlayersManagement() {
const [players, setPlayers] = useImmer<Player[]>([]); const [players, setPlayers] = useImmer<Player[]>([]);
@ -152,9 +152,7 @@ function PlayersManagement() {
return; return;
} }
const {code, message} = await fetch.del<fetch.ResponseBody>( const {code, message} = await fetch.del<fetch.ResponseBody>(urls.admin.players.delete(player.pid));
urls.admin.players.delete(player.pid),
);
if (code === 0) { if (code === 0) {
setPlayers(players => players.filter(({pid}) => pid !== player.pid)); setPlayers(players => players.filter(({pid}) => pid !== player.pid));
toast.success(message); toast.success(message);
@ -208,26 +206,47 @@ function PlayersManagement() {
</label> </label>
</div> </div>
</Header> </Header>
{players.length === 0 && !isLoading ? ( {players.length === 0 && !isLoading
<div className='card-body text-center'>{t('general.noResult')}</div> ? <div className='card-body text-center'>{t('general.noResult')}</div>
) : (isTableMode ? ( : isTableMode
<div className='card-body table-responsive p-0'> ? (
<table className={`table ${isLoading ? '' : 'table-striped'}`}> <div className='card-body table-responsive p-0'>
<thead> <table className={`table ${isLoading ? '' : 'table-striped'}`}>
<tr> <thead>
<th>PID</th> <tr>
<th>{t('general.player.player-name')}</th> <th>PID</th>
<th>{t('general.player.owner')}</th> <th>{t('general.player.player-name')}</th>
<th>{t('general.player.previews')}</th> <th>{t('general.player.owner')}</th>
<th>{t('general.player.last-modified')}</th> <th>{t('general.player.previews')}</th>
<th>{t('admin.operationsTitle')}</th> <th>{t('general.player.last-modified')}</th>
</tr> <th>{t('admin.operationsTitle')}</th>
</thead> </tr>
<tbody> </thead>
<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 {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) => ( : players.map((player, i) => (
<Row <Card
key={player.pid} key={player.pid}
player={player} player={player}
onUpdateName={async () => handleUpdateName(player, i)} onUpdateName={async () => handleUpdateName(player, i)}
@ -238,27 +257,8 @@ function PlayersManagement() {
onDelete={async () => handleDelete(player)} onDelete={async () => handleDelete(player)}
/> />
))} ))}
</tbody> </div>
</table> )}
</div>
) : (
<div className='card-body d-flex flex-wrap'>
{isLoading
? Array.from({length: 10}).fill(null).map((_, i) => <LoadingCard key={i}/>)
: players.map((player, i) => (
<Card
key={player.pid}
player={player}
onUpdateName={async () => handleUpdateName(player, i)}
onUpdateOwner={async () => handleUpdateOwner(player, i)}
onUpdateTexture={() => {
setTextureUpdating(i);
}}
onDelete={async () => handleDelete(player)}
/>
))}
</div>
))}
<div className='card-footer'> <div className='card-footer'>
<div className='float-right'> <div className='float-right'>
<Pagination page={page} totalPages={totalPages} onChange={setPage}/> <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 * as breakpoints from '@/styles/breakpoints';
import styled from '@emotion/styled';
export const Box = styled.div` export const Box = styled.div`
width: 48%; width: 48%;

View File

@ -1,8 +1,8 @@
import styled from '@emotion/styled';
import clsx from 'clsx';
import type {Plugin} from './types'; import type {Plugin} from './types';
import {t} from '@/scripts/i18n'; import {t} from '@/scripts/i18n';
import styled from '@emotion/styled';
import clsx from 'clsx';
const Box = styled.div` const Box = styled.div`
cursor: default; cursor: default;
@ -41,9 +41,9 @@ const Description = styled.div`
type Properties = { type Properties = {
readonly plugin: Plugin; readonly plugin: Plugin;
onEnable(plugin: Plugin): void; onEnable: (plugin: Plugin) => void;
onDisable(plugin: Plugin): void; onDisable: (plugin: Plugin) => void;
onDelete(plugin: Plugin): void; onDelete: (plugin: Plugin) => void;
readonly baseUrl: string; readonly baseUrl: string;
}; };
@ -89,7 +89,8 @@ const InfoBox: React.FC<Properties> = properties => {
{plugin.title} {plugin.title}
</strong> </strong>
<span className='d-none d-sm-inline-block text-gray'> <span className='d-none d-sm-inline-block text-gray'>
v{plugin.version} v
{plugin.version}
</span> </span>
</Header> </Header>
<div> <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 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 FileInput from '@/components/FileInput';
import Loading from '@/components/Loading'; 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() { function PluginsManagement() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -33,9 +33,9 @@ function PluginsManagement() {
message, message,
data: {reason} = {reason: []}, data: {reason} = {reason: []},
} = await fetch.post< } = await fetch.post<
fetch.ResponseBody<{ fetch.ResponseBody<{
reason: string[]; reason: string[];
}> }>
>('/admin/plugins/manage', { >('/admin/plugins/manage', {
action: 'enable', action: 'enable',
name: plugin.name, name: plugin.name,
@ -52,9 +52,8 @@ function PluginsManagement() {
<div> <div>
<p>{message}</p> <p>{message}</p>
<ul> <ul>
{reason.map((t, i) => ( {reason.map((t, i) =>
<li key={i}>{t}</li> <li key={i}>{t}</li>)}
))}
</ul> </ul>
</div> </div>
), ),
@ -166,27 +165,25 @@ function PluginsManagement() {
return ( return (
<div className='row'> <div className='row'>
<div className='col-lg-8'> <div className='col-lg-8'>
{isLoading ? ( {isLoading
<Loading/> ? <Loading/>
) : (plugins.length === 0 ? ( : plugins.length === 0
t('general.noResult') ? t('general.noResult')
) : ( : chunks.map((chunk, i) => (
chunks.map((chunk, i) => ( <div key={`${chunk[0].name}&${chunk[1]?.name}`} className='row'>
<div key={`${chunk[0].name}&${chunk[1]?.name}`} className='row'> {(chunk as Plugin[]).map((plugin, index) => (
{(chunk as Plugin[]).map((plugin, index) => ( <div key={plugin.name} className='col-md-6'>
<div key={plugin.name} className='col-md-6'> <InfoBox
<InfoBox plugin={plugin}
plugin={plugin} baseUrl={blessing.base_url}
baseUrl={blessing.base_url} onEnable={async plugin => handleEnable(plugin, i * 2 + index)}
onEnable={async plugin => handleEnable(plugin, i * 2 + index)} onDisable={async plugin => handleDisable(plugin, i * 2 + index)}
onDisable={async plugin => handleDisable(plugin, i * 2 + index)} onDelete={handleDelete}
onDelete={handleDelete} />
/> </div>
</div> ))}
))} </div>
</div> ))}
))
))}
</div> </div>
<div className='col-lg-4'> <div className='col-lg-4'>
<div className='card card-primary card-outline'> <div className='card card-primary card-outline'>

View File

@ -5,8 +5,8 @@ import {t} from '@/scripts/i18n';
type Properties = { type Properties = {
readonly plugin: Plugin; readonly plugin: Plugin;
readonly isInstalling: boolean; readonly isInstalling: boolean;
onInstall(): void; onInstall: () => void;
onUpdate(): void; onUpdate: () => void;
}; };
const Row: React.FC<Properties> = properties => { const Row: React.FC<Properties> = properties => {
@ -27,63 +27,71 @@ const Row: React.FC<Properties> = properties => {
<td>{plugin.author}</td> <td>{plugin.author}</td>
<td>{plugin.version}</td> <td>{plugin.version}</td>
<td style={{width: '100px'}}> <td style={{width: '100px'}}>
{allDeps.length === 0 ? ( {allDeps.length === 0
<i>{t('admin.noDependencies')}</i> ? <i>{t('admin.noDependencies')}</i>
) : ( : (
<div className='d-flex flex-column'> <div className='d-flex flex-column'>
{allDeps.map(([name, constraint]) => { {allDeps.map(([name, constraint]) => {
const classes = [ const classes = [
'mb-1', 'mb-1',
'badge', 'badge',
`bg-${unsatisfied.includes(name) ? 'red' : 'green'}`, `bg-${unsatisfied.includes(name) ? 'red' : 'green'}`,
]; ];
return ( return (
<span key={name} className={classes.join(' ')}> <span key={name} className={classes.join(' ')}>
{name}: {constraint} {name}
</span> :
); {constraint}
})} </span>
</div> );
)} })}
</div>
)}
</td> </td>
<td style={{width: '12%'}}> <td style={{width: '12%'}}>
{plugin.can_update ? ( {plugin.can_update
<button ? (
className='btn btn-success' <button
disabled={isInstalling} className='btn btn-success'
onClick={properties.onUpdate} disabled={isInstalling}
> onClick={properties.onUpdate}
{isInstalling ? ( >
<> {isInstalling
<i className='fas fa-spinner fa-spin mr-1'/> ? (
{t('admin.pluginUpdating')} <>
</> <i className='fas fa-spinner fa-spin mr-1'/>
) : ( {t('admin.pluginUpdating')}
<> </>
<i className='fas fa-sync-alt mr-1'/> )
{t('admin.updatePlugin')} : (
</> <>
)} <i className='fas fa-sync-alt mr-1'/>
</button> {t('admin.updatePlugin')}
) : ( </>
<button )}
className='btn btn-default' </button>
disabled={properties.isInstalling || Boolean(plugin.installed)} )
onClick={properties.onInstall} : (
> <button
{isInstalling ? ( className='btn btn-default'
<> disabled={properties.isInstalling || Boolean(plugin.installed)}
<i className='fas fa-spinner fa-spin mr-1'/> onClick={properties.onInstall}
{t('admin.pluginInstalling')} >
</> {isInstalling
) : ( ? (
<> <>
<i className='fas fa-download mr-1'/> <i className='fas fa-spinner fa-spin mr-1'/>
{t('admin.installPlugin')} {t('admin.pluginInstalling')}
</> </>
)} )
</button> : (
)} <>
<i className='fas fa-download mr-1'/>
{t('admin.installPlugin')}
</>
)}
</button>
)}
</td> </td>
</tr> </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 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 Loading from '@/components/Loading';
import Pagination from '@/components/Pagination'; import Pagination from '@/components/Pagination';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify';
import {enableMapSet} from 'immer';
import {useEffect, useMemo, useState} from 'react';
import {useImmer} from 'use-immer';
import Row from './Row';
enableMapSet(); enableMapSet();
@ -21,10 +21,8 @@ export default function PluginsMarket() {
const searchedPlugins = useMemo( const searchedPlugins = useMemo(
() => () =>
plugins.filter( plugins.filter(plugin =>
plugin => plugin.name.includes(search) || plugin.title.includes(search)),
plugin.name.includes(search) || plugin.title.includes(search),
),
[plugins, search], [plugins, search],
); );
@ -45,9 +43,7 @@ export default function PluginsMarket() {
setSearch(search); setSearch(search);
setPage(1); setPage(1);
const searchedPlugins = plugins.filter( const searchedPlugins = plugins.filter(plugin => plugin.name.includes(search) || plugin.title.includes(search));
plugin => plugin.name.includes(search) || plugin.title.includes(search),
);
setTotalPages(Math.ceil(searchedPlugins.length / 10)); setTotalPages(Math.ceil(searchedPlugins.length / 10));
}; };
@ -79,9 +75,8 @@ export default function PluginsMarket() {
<div> <div>
<p>{message}</p> <p>{message}</p>
<ul> <ul>
{data.reason.map((t, i) => ( {data.reason.map((t, i) =>
<li key={i}>{t}</li> <li key={i}>{t}</li>)}
))}
</ul> </ul>
</div> </div>
), ),
@ -122,39 +117,41 @@ export default function PluginsMarket() {
onChange={handleSearchChange} onChange={handleSearchChange}
/> />
</div> </div>
{isLoading ? ( {isLoading
<div className='card-body'> ? (
<Loading/> <div className='card-body'>
</div> <Loading/>
) : (searchedPlugins.length === 0 ? ( </div>
<div className='card-body text-center'>{t('general.noResult')}</div> )
) : ( : searchedPlugins.length === 0
<div className='card-body table-responsive p-0'> ? <div className='card-body text-center'>{t('general.noResult')}</div>
<table className='table table-striped'> : (
<thead> <div className='card-body table-responsive p-0'>
<tr> <table className='table table-striped'>
<th>{t('admin.pluginTitle')}</th> <thead>
<th>{t('admin.pluginDescription')}</th> <tr>
<th>{t('admin.pluginAuthor')}</th> <th>{t('admin.pluginTitle')}</th>
<th>{t('admin.pluginVersion')}</th> <th>{t('admin.pluginDescription')}</th>
<th>{t('admin.pluginDependencies')}</th> <th>{t('admin.pluginAuthor')}</th>
<th>{t('admin.operationsTitle')}</th> <th>{t('admin.pluginVersion')}</th>
</tr> <th>{t('admin.pluginDependencies')}</th>
</thead> <th>{t('admin.operationsTitle')}</th>
<tbody> </tr>
{pagedPlugins.map((plugin, i) => ( </thead>
<Row <tbody>
key={plugin.name} {pagedPlugins.map((plugin, i) => (
plugin={plugin} <Row
isInstalling={installings.has(plugin.name)} key={plugin.name}
onInstall={async () => handleInstall(plugin, ((page - 1) * 10) + i)} plugin={plugin}
onUpdate={async () => handleUpdate(plugin, ((page - 1) * 10) + i)} isInstalling={installings.has(plugin.name)}
/> onInstall={async () => handleInstall(plugin, ((page - 1) * 10) + i)}
))} onUpdate={async () => handleUpdate(plugin, ((page - 1) * 10) + i)}
</tbody> />
</table> ))}
</div> </tbody>
))} </table>
</div>
)}
<div className='card-footer'> <div className='card-footer'>
<div className='float-right'> <div className='float-right'>
<Pagination page={page} totalPages={totalPages} onChange={setPage}/> <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 styled from '@emotion/styled';
import {type Report, Status} from './types'; import {type Report, Status} from './types';
import {t} from '@/scripts/i18n';
import type {Texture} from '@/scripts/types';
const Card = styled.div` const Card = styled.div`
width: 240px; width: 240px;
@ -31,10 +31,10 @@ const Card = styled.div`
type Properties = { type Properties = {
readonly report: Report; readonly report: Report;
onClick(texture: Texture | undefined): void; onClick: (texture: Texture | undefined) => void;
onBan(): void; onBan: () => void;
onDelete(): void; onDelete: () => void;
onReject(): void; onReject: () => void;
}; };
const ImageBox: React.FC<Properties> = properties => { const ImageBox: React.FC<Properties> = properties => {
@ -54,7 +54,10 @@ const ImageBox: React.FC<Properties> = properties => {
{': '} {': '}
</b> </b>
<span className='mr-1'>{report.texture_uploader?.nickname}</span> <span className='mr-1'>{report.texture_uploader?.nickname}</span>
(UID: {report.uploader}) (UID:
{' '}
{report.uploader}
)
</div> </div>
<div className='card-body'> <div className='card-body'>
<picture> <picture>
@ -70,14 +73,15 @@ const ImageBox: React.FC<Properties> = properties => {
<div className='card-footer'> <div className='card-footer'>
<div className='d-flex justify-content-between'> <div className='d-flex justify-content-between'>
<div> <div>
{report.status === Status.Pending ? ( {report.status === Status.Pending
<span className='badge bg-warning'>{t('report.status.0')}</span> ? <span className='badge bg-warning'>{t('report.status.0')}</span>
) : (report.status === Status.Resolved ? ( : report.status === Status.Resolved
<span className='badge bg-success'>{t('report.status.1')}</span> ? <span className='badge bg-success'>{t('report.status.1')}</span>
) : ( : <span className='badge bg-danger'>{t('report.status.2')}</span>}
<span className='badge bg-danger'>{t('report.status.2')}</span> <span className='badge bg-info ml-1'>
))} TID:
<span className='badge bg-info ml-1'>TID: {report.tid}</span> {report.tid}
</span>
</div> </div>
<div className='dropdown'> <div className='dropdown'>
<a <a
@ -92,7 +96,8 @@ const ImageBox: React.FC<Properties> = properties => {
<a <a
href={`${blessing.base_url}/skinlib/show/${report.tid}`} href={`${blessing.base_url}/skinlib/show/${report.tid}`}
className='dropdown-item' className='dropdown-item'
target='_blank' rel='noreferrer' target='_blank'
rel='noreferrer'
> >
<i className='fas fa-share-square mr-2'/> <i className='fas fa-share-square mr-2'/>
{t('user.viewInSkinlib')} {t('user.viewInSkinlib')}
@ -122,7 +127,10 @@ const ImageBox: React.FC<Properties> = properties => {
{': '} {': '}
</b> </b>
<span className='mr-1'>{report.informer?.nickname}</span> <span className='mr-1'>{report.informer?.nickname}</span>
(UID: {report.reporter}) (UID:
{' '}
{report.reporter}
)
</div> </div>
<details> <details>
<summary className='text-truncate'> <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 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 Loading from '@/components/Loading';
import Pagination from '@/components/Pagination'; import Pagination from '@/components/Pagination';
import ViewerSkeleton from '@/components/ViewerSkeleton'; 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')); const Previewer = React.lazy(async () => import('@/components/Viewer'));
@ -104,26 +104,28 @@ function ReportsManagement() {
</div> </div>
</form> </form>
</div> </div>
{isLoading ? ( {isLoading
<div className='card-body'> ? (
<Loading/> <div className='card-body'>
</div> <Loading/>
) : (reports.length === 0 ? ( </div>
<div className='card-body text-center'>{t('general.noResult')}</div> )
) : ( : reports.length === 0
<div className='card-body d-flex flex-wrap'> ? <div className='card-body text-center'>{t('general.noResult')}</div>
{reports.map((report, i) => ( : (
<ImageBox <div className='card-body d-flex flex-wrap'>
key={report.id} {reports.map((report, i) => (
report={report} <ImageBox
onClick={setViewingTexture} key={report.id}
onBan={async () => handleProceedReport(report, i, 'ban')} report={report}
onDelete={async () => handleDelete(report, i)} onClick={setViewingTexture}
onReject={async () => handleProceedReport(report, i, 'reject')} onBan={async () => handleProceedReport(report, i, 'ban')}
/> onDelete={async () => handleDelete(report, i)}
))} onReject={async () => handleProceedReport(report, i, 'reject')}
</div> />
))} ))}
</div>
)}
<div className='card-footer'> <div className='card-footer'>
<div className='float-right'> <div className='float-right'>
<Pagination <Pagination

View File

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

View File

@ -1,13 +1,13 @@
import React, {useState, useEffect} from 'react'; import type {Paginator} from '@/scripts/types';
import {useImmer} from 'use-immer';
import type {Line} from './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 {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net'; import * as fetch from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify'; import {showModal, toast} from '@/scripts/notify';
import type {Paginator} from '@/scripts/types'; import React, {useEffect, useState} from 'react';
import Loading from '@/components/Loading'; import {useImmer} from 'use-immer';
import Pagination from '@/components/Pagination'; import Row from './Row';
function Translations() { function Translations() {
const [lines, setLines] = useImmer<Line[]>([]); const [lines, setLines] = useImmer<Line[]>([]);
@ -85,28 +85,30 @@ function Translations() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{isLoading ? ( {isLoading
<tr> ? (
<td className='text-center' colSpan={4}> <tr>
<Loading/> <td className='text-center' colSpan={4}>
</td> <Loading/>
</tr> </td>
) : (lines.length === 0 ? ( </tr>
<tr> )
<td className='text-center' colSpan={4}> : lines.length === 0
{t('general.noResult')} ? (
</td> <tr>
</tr> <td className='text-center' colSpan={4}>
) : ( {t('general.noResult')}
lines.map((line, i) => ( </td>
<Row </tr>
key={line.id} )
line={line} : lines.map((line, i) => (
onEdit={async line => handleEdit(line, i)} <Row
onRemove={handleRemove} key={line.id}
/> line={line}
)) onEdit={async line => handleEdit(line, i)}
))} onRemove={handleRemove}
/>
))}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -1,15 +1,13 @@
import {t} from '../../scripts/i18n';
import {post, type ResponseBody} from '../../scripts/net'; import {post, type ResponseBody} from '../../scripts/net';
import {showModal} from '../../scripts/notify'; import {showModal} from '../../scripts/notify';
import {t} from '../../scripts/i18n';
export default async function handler(event: MouseEvent) { export default async function handler(event: MouseEvent) {
const button = event.target as HTMLButtonElement; const button = event.target as HTMLButtonElement;
button.disabled = true; button.disabled = true;
const text = button.textContent; const text = button.textContent;
button.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t( button.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t('admin.downloading')}`;
'admin.downloading',
)}`;
const {code, message}: ResponseBody = await post('/admin/update/download'); const {code, message}: ResponseBody = await post('/admin/update/download');
button.textContent = text; button.textContent = text;

View File

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

View File

@ -1,5 +1,5 @@
import {Breakpoint, lessThan} from '@/styles/breakpoints';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import {lessThan, Breakpoint} from '@/styles/breakpoints';
const Header = styled.div` const Header = styled.div`
display: flex; 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 {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}>` const ShrinkedSkeleton = styled(Skeleton)<{width?: string}>`
width: ${properties => properties.width}; 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 type {User} from '@/scripts/types';
import ButtonEdit from '@/components/ButtonEdit'; import ButtonEdit from '@/components/ButtonEdit';
import {t} from '@/scripts/i18n';
import {
canModifyPermission,
canModifyUser,
humanizePermission,
verificationStatusText,
} from './utils';
type Properties = { type Properties = {
readonly user: User; readonly user: User;
readonly currentUser: User; readonly currentUser: User;
onEmailChange(): void; onEmailChange: () => void;
onNicknameChange(): void; onNicknameChange: () => void;
onScoreChange(): void; onScoreChange: () => void;
onPermissionChange(): void; onPermissionChange: () => void;
onVerificationToggle(): void; onVerificationToggle: () => void;
onPasswordChange(): void; onPasswordChange: () => void;
onDelete(): void; onDelete: () => void;
}; };
const Row: React.FC<Properties> = properties => { const Row: React.FC<Properties> = properties => {
@ -82,11 +82,9 @@ const Row: React.FC<Properties> = properties => {
title={t('admin.toggleVerification')} title={t('admin.toggleVerification')}
onClick={properties.onVerificationToggle} onClick={properties.onVerificationToggle}
> >
{user.verified ? ( {user.verified
<i className='fas fa-toggle-on'/> ? <i className='fas fa-toggle-on'/>
) : ( : <i className='fas fa-toggle-off'/>}
<i className='fas fa-toggle-off'/>
)}
</a> </a>
)} )}
</td> </td>

View File

@ -1,19 +1,19 @@
import {useState, useEffect, useLayoutEffect} from 'react'; import type {Props as ModalInputProperties} from '@/components/ModalInput';
import {useImmer} from 'use-immer'; import Pagination from '@/components/Pagination';
import Header from './Header';
import Card from './Card';
import LoadingCard from './LoadingCard';
import Row from './Row';
import LoadingRow from './LoadingRow';
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra'; import useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useIsLargeScreen from '@/scripts/hooks/useIsLargeScreen'; import useIsLargeScreen from '@/scripts/hooks/useIsLargeScreen';
import {t} from '@/scripts/i18n'; import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net'; import * as fetch from '@/scripts/net';
import {type User, UserPermission, type Paginator} from '@/scripts/types'; import {showModal, toast} from '@/scripts/notify';
import {toast, showModal} from '@/scripts/notify'; import {type Paginator, type User, UserPermission} from '@/scripts/types';
import urls from '@/scripts/urls'; import urls from '@/scripts/urls';
import type {Props as ModalInputProperties} from '@/components/ModalInput'; import {useEffect, useLayoutEffect, useState} from 'react';
import Pagination from '@/components/Pagination'; 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() { function UsersManagement() {
const [users, setUsers] = useImmer<User[]>([]); const [users, setUsers] = useImmer<User[]>([]);
@ -196,9 +196,7 @@ function UsersManagement() {
}; };
const handleVerificationToggle = async (user: User, index: number) => { const handleVerificationToggle = async (user: User, index: number) => {
const {code, message} = await fetch.put<fetch.ResponseBody>( const {code, message} = await fetch.put<fetch.ResponseBody>(urls.admin.users.verification(user.uid));
urls.admin.users.verification(user.uid),
);
if (code === 0) { if (code === 0) {
toast.success(message); toast.success(message);
setUsers(users => { setUsers(users => {
@ -298,28 +296,52 @@ function UsersManagement() {
</label> </label>
</div> </div>
</Header> </Header>
{users.length === 0 && !isLoading ? ( {users.length === 0 && !isLoading
<div className='card-body text-center'>{t('general.noResult')}</div> ? <div className='card-body text-center'>{t('general.noResult')}</div>
) : (isTableMode ? ( : isTableMode
<div className='card-body table-responsive p-0'> ? (
<table className={`table ${isLoading ? '' : 'table-striped'}`}> <div className='card-body table-responsive p-0'>
<thead> <table className={`table ${isLoading ? '' : 'table-striped'}`}>
<tr> <thead>
<th>UID</th> <tr>
<th>{t('general.user.email')}</th> <th>UID</th>
<th>{t('general.user.nickname')}</th> <th>{t('general.user.email')}</th>
<th>{t('general.user.score')}</th> <th>{t('general.user.nickname')}</th>
<th>{t('admin.permission')}</th> <th>{t('general.user.score')}</th>
<th>{t('admin.verification')}</th> <th>{t('admin.permission')}</th>
<th>{t('general.user.register-at')}</th> <th>{t('admin.verification')}</th>
<th>{t('admin.operationsTitle')}</th> <th>{t('general.user.register-at')}</th>
</tr> <th>{t('admin.operationsTitle')}</th>
</thead> </tr>
<tbody> </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 {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) => ( : users.map((user, i) => (
<Row <Card
key={user.uid} key={user.uid}
user={user} user={user}
currentUser={currentUser} currentUser={currentUser}
@ -327,35 +349,13 @@ function UsersManagement() {
onNicknameChange={async () => handleNicknameChange(user, i)} onNicknameChange={async () => handleNicknameChange(user, i)}
onScoreChange={async () => handleScoreChange(user, i)} onScoreChange={async () => handleScoreChange(user, i)}
onPermissionChange={async () => handlePermissionChange(user, i)} onPermissionChange={async () => handlePermissionChange(user, i)}
onVerificationToggle={async () => onVerificationToggle={async () => handleVerificationToggle(user, i)}
handleVerificationToggle(user, i)}
onPasswordChange={async () => handlePasswordChange(user)} onPasswordChange={async () => handlePasswordChange(user)}
onDelete={async () => handleDelete(user)} onDelete={async () => handleDelete(user)}
/> />
))} ))}
</tbody> </div>
</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 className='card-footer'> <div className='card-footer'>
<div className='float-right'> <div className='float-right'>
<Pagination page={page} totalPages={totalPages} onChange={setPage}/> <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 * as breakpoints from '@/styles/breakpoints';
import styled from '@emotion/styled';
export const Box = styled.div` export const Box = styled.div`
width: 48%; width: 48%;
@ -14,7 +14,7 @@ export const Icon = styled.div<{py?: boolean}>`
width: 70px; width: 70px;
display: flex; display: flex;
justify-content: center; justify-content: center;
padding-top: ${properties => (properties.py ? '22px' : '0')}; padding-top: ${properties => properties.py ? '22px' : '0'};
`; `;
export const InfoTable = styled.div` 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 useEmitMounted from '@/scripts/hooks/useEmitMounted';
import {t} from '@/scripts/i18n'; import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net'; import * as fetch from '@/scripts/net';
import urls from '@/scripts/urls'; import urls from '@/scripts/urls';
import Alert from '@/components/Alert'; import {useRef, useState} from 'react';
import Captcha from '@/components/Captcha';
import EmailSuggestion from '@/components/EmailSuggestion';
export default function Forgot() { export default function Forgot() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [warningMessage, setWarningMessage] = useState(''); const [warningMessage, setWarningMessage] = useState('');
const reference = useRef<Captcha | null>(null); const reference = useRef<Captcha>(null);
useEmitMounted(); useEmitMounted();
@ -57,14 +57,14 @@ export default function Forgot() {
{t('auth.forgot.login-link')} {t('auth.forgot.login-link')}
</a> </a>
<button className='btn btn-primary' type='submit' disabled={isSending}> <button className='btn btn-primary' type='submit' disabled={isSending}>
{isSending ? ( {isSending
<> ? (
<i className='fas fa-spinner fa-spin mr-1'/> <>
{t('auth.sending')} <i className='fas fa-spinner fa-spin mr-1'/>
</> {t('auth.sending')}
) : ( </>
t('auth.send') )
)} : t('auth.send')}
</button> </button>
</div> </div>
</form> </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 useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useEmitMounted from '@/scripts/hooks/useEmitMounted'; import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import {t} from '@/scripts/i18n'; import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net'; import * as fetch from '@/scripts/net';
import {showModal} from '@/scripts/notify'; import {showModal} from '@/scripts/notify';
import urls from '@/scripts/urls'; import urls from '@/scripts/urls';
import Alert from '@/components/Alert'; import {useEffect, useRef, useState} from 'react';
import Captcha from '@/components/Captcha';
import EmailSuggestion from '@/components/EmailSuggestion';
type SuccessfulResponse = { type SuccessfulResponse = {
code: 0; code: 0;
@ -21,9 +21,7 @@ type FailedResponse = {
}; };
type Response = SuccessfulResponse | FailedResponse; type Response = SuccessfulResponse | FailedResponse;
function isSuccessfulResponse( function isSuccessfulResponse(response: Response): response is SuccessfulResponse {
response: Response,
): response is SuccessfulResponse {
return response.code === 0; return response.code === 0;
} }
@ -34,7 +32,7 @@ export default function Login() {
const [hasTooManyFails, setHasTooManyFails] = useState(false); const [hasTooManyFails, setHasTooManyFails] = useState(false);
const [isPending, setIsPending] = useState(false); const [isPending, setIsPending] = useState(false);
const [warningMessage, setWarningMessage] = useState(''); const [warningMessage, setWarningMessage] = useState('');
const reference = useRef<Captcha | null>(null); const reference = useRef<Captcha>(null);
const recaptcha = useBlessingExtra<string>('recaptcha'); const recaptcha = useBlessingExtra<string>('recaptcha');
const invisibleRecaptcha = useBlessingExtra<boolean>('invisible'); const invisibleRecaptcha = useBlessingExtra<boolean>('invisible');
@ -111,10 +109,8 @@ export default function Login() {
value={password} value={password}
onChange={handlePasswordChange} onChange={handlePasswordChange}
/> />
<div className='input-group-append'> <div className='input-group-text'>
<div className='input-group-text'> <i className='fas fa-lock'/>
<i className='fas fa-lock'/>
</div>
</div> </div>
</div> </div>
@ -126,7 +122,7 @@ export default function Login() {
<label> <label>
<input <input
type='checkbox' type='checkbox'
className='mr-1' className='me-1'
checked={remember} checked={remember}
onChange={handleRememberChange} onChange={handleRememberChange}
/> />
@ -140,14 +136,14 @@ export default function Login() {
type='submit' type='submit'
disabled={isPending} disabled={isPending}
> >
{isPending ? ( {isPending
<> ? (
<i className='fas fa-spinner fa-spin mr-1'/> <>
{t('auth.loggingIn')} <i className='fas fa-spinner fa-spin mr-1'/>
</> {t('auth.loggingIn')}
) : ( </>
t('auth.login') )
)} : t('auth.login')}
</button> </button>
</form> </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 useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useEmitMounted from '@/scripts/hooks/useEmitMounted'; import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import {t} from '@/scripts/i18n'; import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net'; import * as fetch from '@/scripts/net';
import {toast} from '@/scripts/notify'; import {toast} from '@/scripts/notify';
import urls from '@/scripts/urls'; import urls from '@/scripts/urls';
import Alert from '@/components/Alert'; import {useRef, useState} from 'react';
import Captcha from '@/components/Captcha';
import EmailSuggestion from '@/components/EmailSuggestion';
export default function Registration() { export default function Registration() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -18,8 +18,8 @@ export default function Registration() {
const [isPending, setIsPending] = useState(false); const [isPending, setIsPending] = useState(false);
const [warningMessage, setWarningMessage] = useState(''); const [warningMessage, setWarningMessage] = useState('');
const requirePlayer = useBlessingExtra<boolean>('player'); const requirePlayer = useBlessingExtra<boolean>('player');
const confirmationReference = useRef<HTMLInputElement | undefined>(null); const confirmationReference = useRef<HTMLInputElement>(null);
const captchaReference = useRef<Captcha | undefined>(null); const captchaReference = useRef<Captcha>(null);
useEmitMounted(); useEmitMounted();
@ -27,9 +27,7 @@ export default function Registration() {
setPassword(event.target.value); setPassword(event.target.value);
}; };
const handleConfirmationChange = ( const handleConfirmationChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event: React.ChangeEvent<HTMLInputElement>,
) => {
setConfirmation(event.target.value); setConfirmation(event.target.value);
}; };
@ -37,9 +35,7 @@ export default function Registration() {
setNickName(event.target.value); setNickName(event.target.value);
}; };
const handlePlayerNameChange = ( const handlePlayerNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event: React.ChangeEvent<HTMLInputElement>,
) => {
setPlayerName(event.target.value); setPlayerName(event.target.value);
}; };
@ -57,8 +53,11 @@ export default function Registration() {
const {code, message} = await fetch.post<fetch.ResponseBody>( const {code, message} = await fetch.post<fetch.ResponseBody>(
urls.auth.register(), urls.auth.register(),
{ {
email, password, captcha: await captchaReference.current!.execute(), email,
...(requirePlayer ? {player_name: playerName} : {nickname: nickName}), password,
captcha: await captchaReference.current!.execute(),
// eslint-disable-next-line ts/naming-convention
...requirePlayer ? {player_name: playerName} : {nickname: nickName},
}, },
); );
if (code === 0) { if (code === 0) {
@ -121,39 +120,41 @@ export default function Registration() {
</div> </div>
</div> </div>
</div> </div>
{requirePlayer ? ( {requirePlayer
<div className='input-group mb-3' title={t('auth.player-name-intro')}> ? (
<input <div className='input-group mb-3' title={t('auth.player-name-intro')}>
required <input
type='text' required
className='form-control' type='text'
placeholder={t('auth.player-name')} className='form-control'
value={playerName} placeholder={t('auth.player-name')}
onChange={handlePlayerNameChange} value={playerName}
/> onChange={handlePlayerNameChange}
<div className='input-group-append'> />
<div className='input-group-text'> <div className='input-group-append'>
<i className='fas fa-gamepad'/> <div className='input-group-text'>
<i className='fas fa-gamepad'/>
</div>
</div> </div>
</div> </div>
</div> )
) : ( : (
<div className='input-group mb-3' title={t('auth.nickname-intro')}> <div className='input-group mb-3' title={t('auth.nickname-intro')}>
<input <input
required required
type='text' type='text'
className='form-control' className='form-control'
placeholder={t('auth.nickname')} placeholder={t('auth.nickname')}
value={nickName} value={nickName}
onChange={handleNickNameChange} onChange={handleNickNameChange}
/> />
<div className='input-group-append'> <div className='input-group-append'>
<div className='input-group-text'> <div className='input-group-text'>
<i className='fas fa-gamepad'/> <i className='fas fa-gamepad'/>
</div>
</div> </div>
</div> </div>
</div> )}
)}
<Captcha ref={captchaReference}/> <Captcha ref={captchaReference}/>
<Alert type='warning'>{warningMessage}</Alert> <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'> <div className='d-flex justify-content-between align-items-center mb-3'>
<a href={`${blessing.base_url}/auth/login`}>{t('auth.login-link')}</a> <a href={`${blessing.base_url}/auth/login`}>{t('auth.login-link')}</a>
<button className='btn btn-primary' type='submit' disabled={isPending}> <button className='btn btn-primary' type='submit' disabled={isPending}>
{isPending ? ( {isPending
<> ? (
<i className='fas fa-spinner fa-spin mr-1'/> <>
{t('auth.registering')} <i className='fas fa-spinner fa-spin mr-1'/>
</> {t('auth.registering')}
) : ( </>
t('auth.register') )
)} : t('auth.register')}
</button> </button>
</div> </div>
</form> </form>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import styled from '@emotion/styled';
import type {LibraryItem} from './types'; import type {LibraryItem} from './types';
import {humanizeType} from './utils';
import {t} from '@/scripts/i18n'; import {t} from '@/scripts/i18n';
import * as cssUtils from '@/styles/utils'; import * as cssUtils from '@/styles/utils';
import styled from '@emotion/styled';
import {humanizeType} from './utils';
const Card = styled.div` const Card = styled.div`
width: 245px; width: 245px;
@ -42,9 +42,9 @@ const ButtonLike = styled.a<ButtonLikeProperties>`
${cssUtils.pointerCursor} ${cssUtils.pointerCursor}
i, span { i, span {
color: ${properties => (properties.liked ? '#dc3545' : '#6c757d')}; color: ${properties => properties.liked ? '#dc3545' : '#6c757d'};
&:hover { &:hover {
color: ${properties => (properties.liked ? '#dc3545' : '#343a40')}; color: ${properties => properties.liked ? '#dc3545' : '#343a40'};
} }
} }
`; `;
@ -52,9 +52,9 @@ const ButtonLike = styled.a<ButtonLikeProperties>`
type Properties = { type Properties = {
readonly item: LibraryItem; readonly item: LibraryItem;
readonly liked: boolean; readonly liked: boolean;
onAdd(texture: LibraryItem): Promise<void>; onAdd: (texture: LibraryItem) => Promise<void>;
onRemove(texture: LibraryItem): Promise<void>; onRemove: (texture: LibraryItem) => Promise<void>;
onUploaderClick(uploader: number): void; onUploaderClick: (uploader: number) => void;
}; };
const Item: React.FC<Properties> = properties => { 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 type {Filter, LibraryItem} from './types';
import Loading from '@/components/Loading';
import Pagination from '@/components/Pagination';
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra'; import useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useEmitMounted from '@/scripts/hooks/useEmitMounted'; import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import {t} from '@/scripts/i18n'; import {t} from '@/scripts/i18n';
@ -11,9 +8,12 @@ import * as fetch from '@/scripts/net';
import {toast} from '@/scripts/notify'; import {toast} from '@/scripts/notify';
import {type Paginator, TextureType} from '@/scripts/types'; import {type Paginator, TextureType} from '@/scripts/types';
import urls from '@/scripts/urls'; import urls from '@/scripts/urls';
import Loading from '@/components/Loading';
import Pagination from '@/components/Pagination';
import removeClosetItem from '@/views/user/Closet/removeClosetItem'; 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() { function SkinLibrary() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -35,16 +35,14 @@ function SkinLibrary() {
const search = new URLSearchParams(query); const search = new URLSearchParams(query);
const filter = search.get('filter') ?? ''; const filter = search.get('filter') ?? '';
setFilter( setFilter([
[ 'skin',
'skin', TextureType.Steve,
TextureType.Steve, TextureType.Alex,
TextureType.Alex, TextureType.Cape,
TextureType.Cape, ].includes(filter)
].includes(filter) ? (filter as Filter)
? (filter as Filter) : 'skin');
: 'skin',
);
const keyword = decodeURIComponent(search.get('keyword') ?? ''); const keyword = decodeURIComponent(search.get('keyword') ?? '');
setName(keyword); setName(keyword);
@ -187,17 +185,19 @@ function SkinLibrary() {
<div className='container-fluid d-flex justify-content-between'> <div className='container-fluid d-flex justify-content-between'>
<h1>{t('general.skinlib')}</h1> <h1>{t('general.skinlib')}</h1>
<span> <span>
{uploader ? ( {uploader
<> ? (
<i className='fas fa-user mr-1'/> <>
{t('skinlib.filter.uploader', {uid: uploader.toString()})} <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')} <>
</> <i className='fas fa-user-friends mr-1'/>
)} {t('skinlib.filter.allUsers')}
</>
)}
</span> </span>
</div> </div>
</div> </div>
@ -263,22 +263,22 @@ function SkinLibrary() {
</div> </div>
</div> </div>
</div> </div>
{items.length > 0 ? ( {items.length > 0
<div className='d-flex flex-wrap'> ? (
{items.map((item, i) => ( <div className='d-flex flex-wrap'>
<Item {items.map((item, i) => (
key={item.tid} <Item
item={item} key={item.tid}
liked={closet.includes(item.tid)} item={item}
onAdd={async item => handleAddToCloset(item, i)} liked={closet.includes(item.tid)}
onRemove={async item => handleRemoveFromCloset(item, i)} onAdd={async item => handleAddToCloset(item, i)}
onUploaderClick={handleUploaderClick} onRemove={async item => handleRemoveFromCloset(item, i)}
/> onUploaderClick={handleUploaderClick}
))} />
</div> ))}
) : ( </div>
<p className='text-center m-5'>{t('general.noResult')}</p> )
)} : <p className='text-center m-5'>{t('general.noResult')}</p>}
</div> </div>
<div className='card-footer'> <div className='card-footer'>
<div className='d-flex justify-content-center'> <div className='d-flex justify-content-center'>

View File

@ -1,16 +1,16 @@
import React, {useState} from 'react'; import FileInput from '@/components/FileInput';
import ReactDOM from 'react-dom'; import ViewerSkeleton from '@/components/ViewerSkeleton';
import {t} from '@/scripts/i18n';
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra'; import useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useEmitMounted from '@/scripts/hooks/useEmitMounted'; import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import useMount from '@/scripts/hooks/useMount'; import useMount from '@/scripts/hooks/useMount';
import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net'; import * as fetch from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify'; import {showModal, toast} from '@/scripts/notify';
import {isAlex} from '@/scripts/textureUtils'; import {isAlex} from '@/scripts/textureUtils';
import {TextureType} from '@/scripts/types'; import {TextureType} from '@/scripts/types';
import urls from '@/scripts/urls'; import urls from '@/scripts/urls';
import FileInput from '@/components/FileInput'; import React, {useState} from 'react';
import ViewerSkeleton from '@/components/ViewerSkeleton'; import ReactDOM from 'react-dom';
const Previewer = React.lazy(async () => import('@/components/Viewer')); const Previewer = React.lazy(async () => import('@/components/Viewer'));
@ -46,9 +46,7 @@ function Upload() {
setIsPrivate(event.target.checked); setIsPrivate(event.target.checked);
}; };
const handleFileChange = async ( const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
event: React.ChangeEvent<HTMLInputElement>,
) => {
const files = event.target.files!; const files = event.target.files!;
const [file] = files; const [file] = files;
if (file) { if (file) {
@ -60,7 +58,7 @@ function Upload() {
const texture = URL.createObjectURL(file); const texture = URL.createObjectURL(file);
setTexture(texture); setTexture(texture);
if (type !== TextureType.Cape) { 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} disabled={isUploading}
onClick={handleUpload} onClick={handleUpload}
> >
{isUploading ? ( {isUploading
<> ? (
<i className='fas fa-spinner fa-spin mr-1'/> <>
<span>{t('skinlib.uploading')}</span> <i className='fas fa-spinner fa-spin mr-1'/>
</> <span>{t('skinlib.uploading')}</span>
) : ( </>
t('skinlib.upload.button') )
)} : t('skinlib.upload.button')}
</button> </button>
</div> </div>
{file && ( {file && (
@ -231,9 +229,8 @@ function Upload() {
</div> </div>
</div> </div>
)} )}
{isPrivate && ( {isPrivate
<div className='callout callout-info mt-3'>{privacyNotice}</div> && <div className='callout callout-info mt-3'>{privacyNotice}</div>}
)}
{!isPrivate && award > 0 && ( {!isPrivate && award > 0 && (
<div className='callout callout-success mt-3'> <div className='callout callout-success mt-3'>
{t('skinlib.upload.award', {score: award.toString()})} {t('skinlib.upload.award', {score: award.toString()})}
@ -242,16 +239,16 @@ function Upload() {
</div> </div>
</div> </div>
{container {container
&& ReactDOM.createPortal( && ReactDOM.createPortal(
<React.Suspense fallback={<ViewerSkeleton/>}> <React.Suspense fallback={<ViewerSkeleton/>}>
<Previewer <Previewer
skin={type === TextureType.Cape ? undefined : texture} skin={type === TextureType.Cape ? undefined : texture}
cape={type === TextureType.Cape ? texture : undefined} cape={type === TextureType.Cape ? texture : undefined}
isAlex={type === TextureType.Alex} isAlex={type === TextureType.Alex}
/> />
</React.Suspense>, </React.Suspense>,
container, 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 setAsAvatar from './setAsAvatar';
import {Card, DropdownButton} from './styles'; import {Card, DropdownButton} from './styles';
import {t} from '@/scripts/i18n';
import type {ClosetItem as ClosetItemType} from '@/scripts/types';
type Properties = { type Properties = {
readonly item: ClosetItemType; readonly item: ClosetItemType;
readonly selected: boolean; readonly selected: boolean;
onClick(item: ClosetItemType): void; onClick: (item: ClosetItemType) => void;
onRename(): void; onRename: () => void;
onRemove(): void; onRemove: () => void;
}; };
const ClosetItem: React.FC<Properties> = properties => { const ClosetItem: React.FC<Properties> = properties => {
@ -58,7 +58,8 @@ const ClosetItem: React.FC<Properties> = properties => {
<a <a
href={`${blessing.base_url}/skinlib/show/${item.tid}`} href={`${blessing.base_url}/skinlib/show/${item.tid}`}
className='dropdown-item' className='dropdown-item'
target='_blank' rel='noreferrer' target='_blank'
rel='noreferrer'
> >
{t('user.viewInSkinlib')} {t('user.viewInSkinlib')}
</a> </a>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,11 +24,11 @@ export function remainingTimeText(remainingTime: number): string {
const time = remainingTime / ONE_MINUTE; const time = remainingTime / ONE_MINUTE;
return time < 60 return time < 60
? t('user.signRemainingTime', { ? t('user.signRemainingTime', {
time: (Math.trunc(time)).toString(), time: Math.trunc(time).toString(),
unit: t('user.timeUnitMin'), unit: t('user.timeUnitMin'),
}) })
: t('user.signRemainingTime', { : t('user.signRemainingTime', {
time: (Math.trunc(time / 60)).toString(), time: Math.trunc(time / 60).toString(),
unit: t('user.timeUnitHour'), 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 Modal from '@/components/Modal';
import {t} from '@/scripts/i18n';
import {useState} from 'react';
type Properties = { type Properties = {
readonly show: boolean; readonly show: boolean;
onCreate(name: string, redirect: string): Promise<void>; onCreate: (name: string, redirect: string) => Promise<void>;
onClose(): void; onClose: () => void;
}; };
const ModalCreate: React.FC<Properties> = properties => { const ModalCreate: React.FC<Properties> = properties => {

View File

@ -1,7 +1,7 @@
import type {App} from './types'; import type {App} from './types';
import {t} from '@/scripts/i18n';
import ButtonEdit from '@/components/ButtonEdit'; import ButtonEdit from '@/components/ButtonEdit';
import {t} from '@/scripts/i18n';
type Properties = { type Properties = {
readonly app: App; 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 type {App} from './types';
import Loading from '@/components/Loading';
import {t} from '@/scripts/i18n'; import {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net'; import * as fetch from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify'; 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 = { type Exception = {
message: string; message: string;
@ -133,14 +133,15 @@ function OAuth() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{apps.length === 0 ? ( {apps.length === 0
<tr> ? (
<td className='text-center' colSpan={5}> <tr>
{isLoading ? <Loading/> : t('general.noResult')} <td className='text-center' colSpan={5}>
</td> {isLoading ? <Loading/> : t('general.noResult')}
</tr> </td>
) : ( </tr>
apps.map((app, i) => ( )
: apps.map((app, i) => (
<Row <Row
key={app.id} key={app.id}
app={app} app={app}
@ -148,8 +149,7 @@ function OAuth() {
onEditRedirect={async () => editRedirect(app, i)} onEditRedirect={async () => editRedirect(app, i)}
onDelete={async () => handleDelete(app)} onDelete={async () => handleDelete(app)}
/> />
)) ))}
)}
</tbody> </tbody>
</table> </table>
</div> </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 {t} from '@/scripts/i18n';
import * as fetch from '@/scripts/net'; import * as fetch from '@/scripts/net';
import {toast} from '@/scripts/notify'; import {toast} from '@/scripts/notify';
import type {Player} from '@/scripts/types';
import urls from '@/scripts/urls'; import urls from '@/scripts/urls';
import Modal from '@/components/Modal'; import {useState} from 'react';
type Extra = { type Extra = {
score: number; score: number;
@ -15,8 +15,8 @@ type Extra = {
type Properties = { type Properties = {
readonly show: boolean; readonly show: boolean;
onAdd(player: Player): void; onAdd: (player: Player) => void;
onClose(): void; onClose: () => void;
}; };
const ModalAddPlayer: React.FC<Properties> = properties => { const ModalAddPlayer: React.FC<Properties> = properties => {
@ -81,7 +81,9 @@ const ModalAddPlayer: React.FC<Properties> = properties => {
> >
<i className={`icon fas fa-${isScoreEnough ? 'check' : 'times'}`}/> <i className={`icon fas fa-${isScoreEnough ? 'check' : 'times'}`}/>
<span className='ml-1'> <span className='ml-1'>
{t('user.cur-score')} {score} {t('user.cur-score')}
{' '}
{score}
</span> </span>
</div> </div>
</Modal> </Modal>

View File

@ -1,11 +1,11 @@
import {useState} from 'react';
import {t} from '@/scripts/i18n';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import {t} from '@/scripts/i18n';
import {useState} from 'react';
type Properties = { type Properties = {
readonly show: boolean; readonly show: boolean;
onSubmit(skin: boolean, cape: boolean): Promise<void>; onSubmit: (skin: boolean, cape: boolean) => Promise<void>;
onClose(): void; onClose: () => void;
}; };
const ModalReset: React.FC<Properties> = properties => { 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 React, {useState} from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import Viewer2d from './Viewer2d'; 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')); const Viewer3d = React.lazy(async () => import('@/components/Viewer'));
@ -32,20 +32,22 @@ const Previewer: React.FC<Properties> = properties => {
return ( return (
container container
&& ReactDOM.createPortal( && ReactDOM.createPortal(
is3d ? ( is3d
<React.Suspense fallback={<ViewerSkeleton/>}> ? (
<Viewer3d skin={skin} cape={cape} isAlex={isAlex}> <React.Suspense fallback={<ViewerSkeleton/>}>
{switcher} <Viewer3d skin={skin} cape={cape} isAlex={isAlex}>
</Viewer3d> {switcher}
</React.Suspense> </Viewer3d>
) : ( </React.Suspense>
<Viewer2d skin={skin} cape={cape}> )
{switcher} : (
</Viewer2d> <Viewer2d skin={skin} cape={cape}>
), {switcher}
container, </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 type {Player} from '@/scripts/types';
import ButtonEdit from '@/components/ButtonEdit'; import ButtonEdit from '@/components/ButtonEdit';
import {t} from '@/scripts/i18n';
import * as cssUtils from '@/styles/utils'; import * as cssUtils from '@/styles/utils';
import {css} from '@emotion/react';
type Properties = { type Properties = {
player: Player; player: Player;
selected: boolean; selected: boolean;
onClick: React.MouseEventHandler; onClick: React.MouseEventHandler;
onEditName(player: Player): Promise<void>; onEditName: (player: Player) => Promise<void>;
onReset(): void; onReset: () => void;
onDelete(player: Player): Promise<void>; onDelete: (player: Player) => Promise<void>;
}; };
const Row: React.FC<Properties> = properties => { const Row: React.FC<Properties> = properties => {
@ -27,7 +26,7 @@ const Row: React.FC<Properties> = properties => {
const selected const selected
= properties.selected = properties.selected
&& css` && css`
background: #efefef; background: #efefef;
.dark-mode & { .dark-mode & {
background: var(--dark); background: var(--dark);

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import {t} from '@/scripts/i18n'; import {t} from '@/scripts/i18n';
import styled from '@emotion/styled';
const TexturePreview = styled.div` const TexturePreview = styled.div`
display: flex; display: flex;
@ -27,19 +27,15 @@ const Viewer2d: React.FC<Properties> = properties => (
<div className='card-body'> <div className='card-body'>
<TexturePreview className='mb-5'> <TexturePreview className='mb-5'>
<span>{t('general.skin')}</span> <span>{t('general.skin')}</span>
{properties.skin ? ( {properties.skin
<img src={properties.skin} alt={t('general.skin')}/> ? <img src={properties.skin} alt={t('general.skin')}/>
) : ( : <span>{t('user.player.texture-empty')}</span>}
<span>{t('user.player.texture-empty')}</span>
)}
</TexturePreview> </TexturePreview>
<TexturePreview className='mt-5'> <TexturePreview className='mt-5'>
<span>{t('general.cape')}</span> <span>{t('general.cape')}</span>
{properties.cape ? ( {properties.cape
<img src={properties.cape} alt={t('general.cape')}/> ? <img src={properties.cape} alt={t('general.cape')}/>
) : ( : <span>{t('user.player.texture-empty')}</span>}
<span>{t('user.player.texture-empty')}</span>
)}
</TexturePreview> </TexturePreview>
</div> </div>
<div className='card-footer'>{properties.children}</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 useBlessingExtra from '@/scripts/hooks/useBlessingExtra';
import useEmitMounted from '@/scripts/hooks/useEmitMounted'; import useEmitMounted from '@/scripts/hooks/useEmitMounted';
import useTexture from '@/scripts/hooks/useTexture'; import useTexture from '@/scripts/hooks/useTexture';
@ -12,6 +6,12 @@ import * as fetch from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify'; import {showModal, toast} from '@/scripts/notify';
import {type Player, TextureType} from '@/scripts/types'; import {type Player, TextureType} from '@/scripts/types';
import urls from '@/scripts/urls'; 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() { function Players() {
const [players, setPlayers] = useState<Player[]>([]); const [players, setPlayers] = useState<Player[]>([]);
@ -104,9 +104,7 @@ function Players() {
search.append('cape', 'true'); search.append('cape', 'true');
} }
const {code, message} = await fetch.del<fetch.ResponseBody>( const {code, message} = await fetch.del<fetch.ResponseBody>(`${urls.user.player.clear(selected)}?${search.toString()}`);
`${urls.user.player.clear(selected)}?${search.toString()}`,
);
if (code === 0) { if (code === 0) {
toast.success(message); toast.success(message);
if (skin) { if (skin) {
@ -147,9 +145,7 @@ function Players() {
return; return;
} }
const {code, message} = await fetch.del<fetch.ResponseBody>( const {code, message} = await fetch.del<fetch.ResponseBody>(urls.user.player.delete(player.pid));
urls.user.player.delete(player.pid),
);
if (code === 0) { if (code === 0) {
toast.success(message); toast.success(message);
const {pid} = player; const {pid} = player;
@ -196,33 +192,33 @@ function Players() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{isLoading ? ( {isLoading
new Array(playersCount) ? new Array(playersCount)
.fill(null) .fill(null)
.map((_, i) => <LoadingRow key={i}/>) .map((_, i) => <LoadingRow key={i}/>)
) : (players.length === 0 ? ( : players.length === 0
<tr> ? (
<td className='text-center' colSpan={3}> <tr>
{t('general.noResult')} <td className='text-center' colSpan={3}>
</td> {t('general.noResult')}
</tr> </td>
) : ( </tr>
players )
.filter(({name}) => name.includes(search)) : players
.map((player, i) => ( .filter(({name}) => name.includes(search))
<Row .map((player, i) => (
key={player.pid} <Row
player={player} key={player.pid}
selected={selected === player.pid} player={player}
onClick={() => { selected={selected === player.pid}
selectPlayer(player); onClick={() => {
}} selectPlayer(player);
onEditName={async () => editName(player, i)} }}
onReset={openModalReset} onEditName={async () => editName(player, i)}
onDelete={deletePlayer} onReset={openModalReset}
/> onDelete={deletePlayer}
)) />
))} ))}
</tbody> </tbody>
</table> </table>
</div> </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 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'); const buttonResetAvatar = document.querySelector('#reset-avatar');
buttonResetAvatar?.addEventListener('click', resetAvatar); buttonResetAvatar?.addEventListener('click', resetAvatar);
@ -16,7 +16,5 @@ nicknameForm?.addEventListener('submit', nicknameFormHandler);
const emailForm = document.querySelector<HTMLFormElement>('#change-email'); const emailForm = document.querySelector<HTMLFormElement>('#change-email');
emailForm?.addEventListener('submit', emailFormHandler); emailForm?.addEventListener('submit', emailFormHandler);
const deleteAccountForm = document.querySelector<HTMLFormElement>( const deleteAccountForm = document.querySelector<HTMLFormElement>('#modal-delete-account');
'#modal-delete-account',
);
deleteAccountForm?.addEventListener('submit', deleteAccountFormHandler); deleteAccountForm?.addEventListener('submit', deleteAccountFormHandler);

View File

@ -1,5 +1,5 @@
import {post, type ResponseBody} from '@/scripts/net';
import {t} from '@/scripts/i18n'; import {t} from '@/scripts/i18n';
import {post, type ResponseBody} from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify'; import {showModal, toast} from '@/scripts/notify';
export default async function handler(event: Event) { 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 {t} from '@/scripts/i18n';
import {post, type ResponseBody} from '@/scripts/net'; import {post, type ResponseBody} from '@/scripts/net';
import {showModal, toast} from '@/scripts/notify';
export default async function resetAvatar() { export default async function resetAvatar() {
try { try {

View File

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

View File

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