diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 4af89ce7..10cc8d4a 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -9,10 +9,9 @@ services: # Update 'VARIANT' to pick a version of PHP version: 8, 8.1, 8.0, 7, 7.4 # Append -bullseye or -buster to pin to an OS version. # Use -bullseye variants on local arm64/Apple Silicon. - VARIANT: "8-bullseye" + VARIANT: 8-bullseye # Optional Node.js version - NODE_VERSION: "lts/*" - + NODE_VERSION: 'lts/*' volumes: - ..:/workspace:cached diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 97be921f..00000000 --- a/.eslintignore +++ /dev/null @@ -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 diff --git a/.gitpod.yml b/.gitpod.yml index 3ee4f675..02c75cde 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -13,28 +13,15 @@ tasks: php artisan serve --host=0.0.0.0 - command: gp ports await 8080 && gp preview $(gp url 8000) -github: - prebuilds: - # enable for the master/default branch (defaults to true) - master: true - # enable for all branches in this repo (defaults to false) - branches: false - # enable for pull requests coming from this repo (defaults to true) - pullRequests: true - # add a check to pull requests (defaults to true) - addCheck: true - # add a "Review in Gitpod" button as a comment to pull requests (defaults to false) - addComment: false - vscode: extensions: - - 'editorconfig.editorconfig' - - 'eamodio.gitlens' - - 'bmewburn.vscode-intelephense-client' - - 'esbenp.prettier-vscode' - - 'jpoissonnier.vscode-styled-components' - - 'mblode.twig-language-2' - - 'felixfbecker.php-debug' + - editorconfig.editorconfig + - eamodio.gitlens + - bmewburn.vscode-intelephense-client + - esbenp.prettier-vscode + - jpoissonnier.vscode-styled-components + - mblode.twig-language-2 + - felixfbecker.php-debug ports: - port: 8080 diff --git a/.vscode/launch.json b/.vscode/launch.json index 3ce44757..c19d9468 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,38 +1,38 @@ { - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Launch Jest Tests", - "program": "${workspaceFolder}/node_modules/.bin/jest", - "args": ["${file}"], - "internalConsoleOptions": "openOnSessionStart", - "skipFiles": [ - "/**" - ] - }, - { - "type": "php", - "request": "launch", - "name": "Launch with XDebug", - "ignore": [ - "**/vendor/**/*.php" - ] - }, - { - "type": "firefox", - "request": "launch", - "reAttach": true, - "name": "Launch with Firefox Debugger", - "url": "http://localhost/", - "webRoot": "${workspaceFolder}", - "pathMappings": [ - { - "url": "webpack:///", - "path": "${workspaceFolder}/" - } - ] - } - ] + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Jest Tests", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["${file}"], + "internalConsoleOptions": "openOnSessionStart", + "skipFiles": [ + "/**" + ] + }, + { + "type": "php", + "request": "launch", + "name": "Launch with XDebug", + "ignore": [ + "**/vendor/**/*.php" + ] + }, + { + "type": "firefox", + "request": "launch", + "reAttach": true, + "name": "Launch with Firefox Debugger", + "url": "http://localhost/", + "webRoot": "${workspaceFolder}", + "pathMappings": [ + { + "url": "webpack:///", + "path": "${workspaceFolder}/" + } + ] + } + ] } diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..a0eeff23 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,11 @@ +import {configBuilder} from '@mochaa/eslintrc'; + +export default configBuilder({ + ignores: [ + 'public/', + 'vendor/', + 'vendor/', + 'plugins/', + 'storage/', + ], +}); diff --git a/package.json b/package.json index 011dafa8..e9aa7f30 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "name": "blessing-skin-server", + "type": "module", "version": "6.0.2", "private": true, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", "description": "A web application brings your custom skins back in offline Minecraft servers.", + "author": "printempw", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/bs-community/blessing-skin-server" }, - "license": "MIT", - "author": "printempw", - "type": "module", "scripts": { "build": "vite build", "build:urls": "ts-node tools/generateUrls.ts", @@ -22,109 +23,64 @@ "iOS >= 12.5", "Chrome >= 87" ], - "eslintConfig": { - "env": { - "es2024": true - }, - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module" - }, - "extends": [ - "xo", - "xo-react", - "plugin:react/jsx-runtime", - "./node_modules/xo/config/plugins.cjs" - ], - "rules": { - "import/extensions": "off", - "import/no-named-as-default": "off", - "n/file-extension-in-import": "off", - "unicorn/filename-case": "off", - "n/prefer-global/process": "off" - }, - "overrides": [ - { - "files": [ - "*.ts", - "*.tsx" - ], - "extends": [ - "xo-typescript" - ], - "rules": { - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/consistent-type-definitions": "warn", - "@typescript-eslint/naming-convention": "warn" - } - } - ], - "ignorePatterns": [ - "dist", - "public" - ], - "root": true - }, - "resolutions": { - "kleur": "^4.1.3" - }, "dependencies": { - "@emotion/react": "^11.11.4", - "@emotion/styled": "^11.0.0", - "@fortawesome/fontawesome-free": "^6.3.0", - "@tweenjs/tween.js": "^23.1.1", - "admin-lte": "next", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@fortawesome/fontawesome-free": "^6.7.2", + "@tweenjs/tween.js": "^25.0.0", + "admin-lte": "4.0.0-beta3", "bootstrap": "^5.3.3", - "clsx": "^2.1.0", - "echarts": "^5.5.0", - "immer": "^10.0.3", + "clsx": "^2.1.1", + "downshift": "^9.0.8", + "echarts": "^5.6.0", + "immer": "^10.1.1", "jquery": "^3.6.0", "lodash-es": "^4.0.8", - "nanoid": "^5.0.6", + "nanoid": "^5.0.9", "prompts": "^2.4.0", - "react": "^18.2.0", - "react-autosuggest": "^10.0.2", - "react-dom": "^18.2.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", "react-draggable": "^4.4.2", - "react-loading-skeleton": "^3.4.0", - "react-use": "^17.5.0", + "react-loading-skeleton": "^3.5.0", + "react-use": "^17.6.0", "reaptcha": "^1.7.2", "rxjs": "^7.8.1", "skinview-utils": "^0.7.1", - "skinview3d": "^3.0.0-alpha.1", - "spectre.css": "npm:@angular-package/spectre.css", - "use-immer": "^0.9.0" + "skinview3d": "^3.1.0", + "spectre.css": "github:angular-package/spectre.css", + "use-immer": "^0.11.0" }, "devDependencies": { - "@testing-library/jest-dom": "^6.4.2", - "@testing-library/react": "^14.2.1", - "@tsconfig/vite-react": "^3.0.0", + "@eslint-react/eslint-plugin": "^1.23.2", + "@mochaa/eslintrc": "^0.1.12", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@tsconfig/vite-react": "^3.4.0", "@types/bootstrap": "^5.2.10", - "@types/jquery": "^3.5.13", + "@types/jquery": "^3.5.32", "@types/js-yaml": "^4.0.9", "@types/lodash-es": "^4.0.6", "@types/prompts": "^2.0.9", - "@types/react": "^18.2.62", - "@types/react-autosuggest": "^10.1.11", - "@types/react-dom": "^18.2.19", + "@types/react": "^18", + "@types/react-dom": "^18", "@types/tween.js": "^18.5.0", - "@vitejs/plugin-react-swc": "^3.6.0", - "autoprefixer": "^10.4.18", - "browserslist": "^4.23.0", + "@vitejs/plugin-react-swc": "^3.7.2", + "autoprefixer": "^10.4.20", + "browserslist": "^4.24.4", "browserslist-to-esbuild": "^2.1.1", - "eslint-config-xo-react": "^0.27.0", - "eslint-plugin-react": "^7.34.0", - "eslint-plugin-react-hooks": "^4.6.0", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.18", "js-yaml": "^4.1.0", - "laravel-vite-plugin": "^1.0.2", - "postcss": "^8.4.35", - "sass": "^1.71.1", - "typescript": "^5.3.3", - "vite": "^5.1.5", - "vite-plugin-top-level-await": "^1.4.1", - "vite-plugin-wasm": "^3.3.0", - "vitest": "^1.3.1", - "xo": "^0.57.0" + "laravel-vite-plugin": "^1.1.1", + "postcss": "^8.5.1", + "sass": "^1.83.4", + "typescript": "^5.7.3", + "vite": "^6.0.7", + "vitest": "^3.0.2" + }, + "resolutions": { + "kleur": "^4.1.3" }, "postcss": { "plugins": { diff --git a/public/.gitignore b/public/.gitignore index 2afdd76e..0c8680c1 100644 --- a/public/.gitignore +++ b/public/.gitignore @@ -1,2 +1,3 @@ app/ build/ +hot diff --git a/resources/assets/src/components/Alert.tsx b/resources/assets/src/components/Alert.tsx index e05b914b..9e587540 100644 --- a/resources/assets/src/components/Alert.tsx +++ b/resources/assets/src/components/Alert.tsx @@ -1,4 +1,3 @@ - type AlertType = 'success' | 'info' | 'warning' | 'danger'; const icons = new Map([ @@ -13,16 +12,17 @@ type Properties = { readonly children?: React.ReactNode; }; -const Alert: React.FC = properties => { - const {type} = properties; +const Alert: React.FC = ({type, children}) => { const icon = icons.get(type); - return properties.children ? ( -
- - {properties.children} -
- ) : null; + return children === '' + ? null + : ( +
+ + {children} +
+ ); }; export default Alert; diff --git a/resources/assets/src/components/ButtonEdit.tsx b/resources/assets/src/components/ButtonEdit.tsx index 076134e7..4f5c11f3 100644 --- a/resources/assets/src/components/ButtonEdit.tsx +++ b/resources/assets/src/components/ButtonEdit.tsx @@ -1,11 +1,10 @@ - type Properties = { readonly title?: string; readonly onClick: React.MouseEventHandler; }; -const ButtonEdit: React.FC = properties => ( - +const ButtonEdit: React.FC = ({title, onClick}) => ( + ); diff --git a/resources/assets/src/components/Captcha.tsx b/resources/assets/src/components/Captcha.tsx index d18a1698..dd1a5831 100644 --- a/resources/assets/src/components/Captcha.tsx +++ b/resources/assets/src/components/Captcha.tsx @@ -1,11 +1,10 @@ -/** @jsxImportSource @emotion/react */ -import React from 'react'; -import Reaptcha from 'reaptcha'; import {emit, on} from '@/scripts/event'; import {t} from '@/scripts/i18n'; import * as cssUtils from '@/styles/utils'; +import React from 'react'; +import Reaptcha from 'reaptcha'; -const eventId = Symbol(); +const eventId = Symbol('EventId'); type State = { value: string; @@ -16,20 +15,22 @@ type State = { class Captcha extends React.Component, State> { state: State; - ref: React.MutableRefObject; + // eslint-disable-next-line ts/no-restricted-types + ref: React.RefObject; constructor(properties: Record) { super(properties); this.state = { value: '', time: Date.now(), - sitekey: blessing.extra.recaptcha, - invisible: blessing.extra.invisible, + sitekey: blessing.extra.recaptcha as string, + invisible: blessing.extra.invisible as boolean, }; - this.ref = React.createRef(); + this.ref = React.createRef(); } - execute = async () => { + // eslint-disable-next-line react/no-unused-class-component-members + async execute() { const recaptcha = this.ref.current; if (recaptcha && this.state.invisible) { return new Promise(resolve => { @@ -37,21 +38,22 @@ class Captcha extends React.Component, State> { resolve(value); off(); }); - recaptcha.execute(); + void recaptcha.execute(); }); } return this.state.value; - }; + } - reset = () => { + // eslint-disable-next-line react/no-unused-class-component-members + reset() { const recaptcha = this.ref.current; if (recaptcha) { - recaptcha.reset(); + void recaptcha.reset(); } else { this.setState({time: Date.now()}); } - }; + } handleValueChange = (event: React.ChangeEvent) => { this.setState({value: event.target.value}); @@ -67,37 +69,39 @@ class Captcha extends React.Component, State> { }; render() { - return this.state.sitekey ? ( -
- -
- ) : ( -
-
- +
- {t('auth.captcha')} -
- ); + ) + : ( +
+
+ +
+ {t('auth.captcha')} +
+ ); } } diff --git a/resources/assets/src/components/DarkModeButton.tsx b/resources/assets/src/components/DarkModeButton.tsx index a8313b24..55b14691 100644 --- a/resources/assets/src/components/DarkModeButton.tsx +++ b/resources/assets/src/components/DarkModeButton.tsx @@ -1,5 +1,5 @@ -import {useState} from 'react'; import * as fetch from '@/scripts/net'; +import {useState} from 'react'; type Properties = { readonly initMode: boolean; diff --git a/resources/assets/src/components/EmailSuggestion.tsx b/resources/assets/src/components/EmailSuggestion.tsx index 72f16c89..dec3d1c9 100644 --- a/resources/assets/src/components/EmailSuggestion.tsx +++ b/resources/assets/src/components/EmailSuggestion.tsx @@ -1,10 +1,9 @@ -/** @jsxImportSource @emotion/react */ - -import {useState, useEffect} from 'react'; -import Autosuggest from 'react-autosuggest'; -import {css} from '@emotion/react'; import {emit} from '@/scripts/event'; import {pointerCursor} from '@/styles/utils'; +import {css} from '@emotion/react'; +import clsx from 'clsx'; +import {useCombobox} from 'downshift'; +import {useEffect, useState} from 'react'; const styles = css` .dropdown-menu li { @@ -14,75 +13,53 @@ const styles = css` const domainNames = new Set(['qq.com', '163.com', 'gmail.com', 'hotmail.com']); -type Properties = Omit, 'onChange'> & { - onChange(value: string): void; +type Properties = Omit, 'onChange'> & { + onChange: (value: string) => void; }; -const EmailSuggestion: React.FC = properties => { - const [suggestions, setSuggestions] = useState([]); - +const EmailSuggestion: React.FC = props => { useEffect(() => { emit('emailDomainsSuggestion', domainNames); }, []); + const [inputItems, setInputItems] = useState([]); - const handleSuggestionsFetchRequested: Autosuggest.SuggestionsFetchRequested - = ({value}) => { - const segments = value.split('@'); - setSuggestions([...domainNames].map(name => `${segments[0]}@${name}`)); - }; + const { + isOpen, + getLabelProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + } = useCombobox({ + items: inputItems, + onInputValueChange({inputValue: value}) { + setInputItems([...domainNames].map(name => `${value.split('@')[0]}@${name}`)); + if (value.length === 0 || value.includes('@')) { + setInputItems([]); + } - const handleSuggestionsClearRequested = () => { - setSuggestions([]); - }; + const {onChange} = props; + onChange(value); + }, + }); - const shouldRenderSuggestions = (value: string) => { - const isSelecting = [...domainNames].some(name => - value.endsWith(`@${name}`), - ); - - return isSelecting || (value.length > 0 && !value.includes('@')); - }; - - const getSuggestionValue = (value: string) => value; - - const renderSuggestion = (suggestion: string) => suggestion; - - const handleChange = (_: React.FormEvent, event: Autosuggest.ChangeEvent) => { - properties.onChange(event.newValue); - }; - - const renderInputComponent = ( - properties_: Omit, 'onChange'>, - ) => ( -
- -
-
+ return ( +
+
+ +
-
- ); - - return ( -
- 0 ? 'show' : ''}`, - suggestionHighlighted: 'active', - }} - onSuggestionsFetchRequested={handleSuggestionsFetchRequested} - onSuggestionsClearRequested={handleSuggestionsClearRequested} - /> +
+
    0 && 'show')} {...getMenuProps()}> + {isOpen && inputItems.length > 0 && inputItems.map((item, index) => ( +
  • + {item} +
  • + ))} +
+
); }; diff --git a/resources/assets/src/components/FileInput.tsx b/resources/assets/src/components/FileInput.tsx index 29f4f8f6..0313daf3 100644 --- a/resources/assets/src/components/FileInput.tsx +++ b/resources/assets/src/components/FileInput.tsx @@ -1,7 +1,6 @@ -/** @jsxImportSource @emotion/react */ -import {useRef} from 'react'; -import {css} from '@emotion/react'; import {t} from '@/scripts/i18n'; +import {css} from '@emotion/react'; +import {useRef} from 'react'; const hideRawBrowseButton = css` ::after { @@ -12,7 +11,7 @@ const hideRawBrowseButton = css` type Properties = { file: File | undefined; accept?: string; - onChange(event: React.ChangeEvent): void; + onChange: (event: React.ChangeEvent) => void; }; const FileInput: React.FC = properties => { diff --git a/resources/assets/src/components/Modal.tsx b/resources/assets/src/components/Modal.tsx index 4ac8427f..07a5ce20 100644 --- a/resources/assets/src/components/Modal.tsx +++ b/resources/assets/src/components/Modal.tsx @@ -1,16 +1,16 @@ -import {useState, useEffect, useRef} from 'react'; -import $ from 'jquery'; -import 'bootstrap'; +import {Modal as BootstrapModal} from 'bootstrap'; +import clsx from 'clsx'; +import {useEffect, useRef, useState} from 'react'; import {t} from '../scripts/i18n'; -import ModalHeader, {type Props as HeaderProperties} from './ModalHeader'; import ModalBody, {type Props as BodyProperties} from './ModalBody'; import ModalFooter, {type Props as FooterProperties} from './ModalFooter'; +import ModalHeader, {type Props as HeaderProperties} from './ModalHeader'; type BasicOptions = { readonly mode?: 'alert' | 'confirm' | 'prompt'; readonly show?: boolean; readonly input?: string; - validator?(value: any): string | boolean | undefined; + validator?: (value: any) => string | boolean | undefined; readonly type?: string; readonly showHeader?: boolean; readonly center?: boolean; @@ -23,9 +23,9 @@ type Properties = { readonly id?: string; readonly children?: React.ReactNode; readonly footer?: React.ReactNode; - onConfirm?(payload: {value: string}): void; - onDismiss?(): void; - onClose?(): void; + onConfirm?: (payload: {value: string}) => void; + onDismiss?: () => void; + onClose?: () => void; }; export type ModalResult = { @@ -49,14 +49,36 @@ const Modal: React.FC = properties => { cancelButtonText = t('general.cancel'), cancelButtonType = 'secondary', flexFooter = false, + footer, + show, + onClose, + onDismiss, + id, + validator, + onConfirm, + children, + choices, + dangerousHTML: html, } = properties; const [value, setValue] = useState(input); const [valid, setValid] = useState(true); const [validatorMessage, setValidatorMessage] = useState(''); const reference = useRef(null); + const [modal, setModal] = useState(); - const {show, onClose} = properties; + useEffect(() => { + if (!reference.current) { + return; + } + + const _modal = new BootstrapModal(reference.current); + setModal(_modal); + + return () => { + _modal.dispose(); + }; + }, [reference]); useEffect(() => { if (!show) { @@ -64,23 +86,26 @@ const Modal: React.FC = properties => { } const onHidden = () => { - onClose ? onClose() : void 0; + onClose?.(); }; - const element = $(reference.current!); - element.on('hidden.bs.modal', onHidden); + const element = reference.current; + if (!element) { + return; + } + + element.addEventListener('hidden.bs.modal', onHidden); return () => { - element.off('hidden.bs.modal', onHidden); + element.removeEventListener('hidden.bs.modal', onHidden); }; - }, [show, onClose]); + }, [reference, show, onClose]); const handleInputChange = (event: React.ChangeEvent) => { setValue(event.target.value); }; const confirm = () => { - const {validator} = properties; if (typeof validator === 'function') { const result = validator(value); if (typeof result === 'string') { @@ -90,49 +115,54 @@ const Modal: React.FC = properties => { } } - properties.onConfirm?.({value}); - $(reference.current!).modal('hide'); + onConfirm?.({value}); + modal?.hide(); // The "hidden.bs.modal" event can't be trigged automatically when testing. - if (process.env.NODE_ENV === 'test') { + if (import.meta.env.NODE_ENV === 'test') { $(reference.current!).trigger('hidden.bs.modal'); } }; const dismiss = () => { - properties.onDismiss?.(); - $(reference.current!).modal('hide'); + onDismiss?.(); + modal?.hide(); - if (process.env.NODE_ENV === 'test') { + if (import.meta.env.NODE_ENV === 'test') { $(reference.current!).trigger('hidden.bs.modal'); } }; useEffect(() => { - if (show) { - setTimeout(() => $(reference.current!).modal('show'), 50); + if (show && modal) { + const timeout = setTimeout(() => { + modal.show(); + }, 50); + return () => { + clearTimeout(timeout); + }; } - }, [show]); + }, [show, modal]); if (!show) { return null; } return ( -