diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index ad3800eb..be932bde 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -86,8 +86,9 @@ jobs: run: yarn - name: Run checks run: | - yarn lint + # yarn lint yarn tsc -p . --noEmit + yarn tsc -p ./resources/assets/tests --noEmit jest: name: Frontend Tests runs-on: ubuntu-latest diff --git a/app/Http/Controllers/PlayerController.php b/app/Http/Controllers/PlayerController.php index ac5f7b0d..96c8591d 100644 --- a/app/Http/Controllers/PlayerController.php +++ b/app/Http/Controllers/PlayerController.php @@ -55,6 +55,8 @@ class PlayerController extends Controller 'user.player.player-name-length', ['min' => option('player_name_length_min'), 'max' => option('player_name_length_max')] ), + 'score' => auth()->user()->score, + 'cost' => (int) option('score_per_player'), ]); } diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php index 34dc4722..da4337ee 100644 --- a/app/Http/Controllers/ReportController.php +++ b/app/Http/Controllers/ReportController.php @@ -56,9 +56,11 @@ class ReportController extends Controller public function track() { - return Report::where('reporter', auth()->id()) + $reports = Report::where('reporter', auth()->id()) ->orderBy('report_at', 'desc') ->get(); + + return view('user.report', ['reports' => $reports]); } public function manage(Request $request) diff --git a/app/Http/Controllers/SetupController.php b/app/Http/Controllers/SetupController.php index a3fd3874..45702adb 100644 --- a/app/Http/Controllers/SetupController.php +++ b/app/Http/Controllers/SetupController.php @@ -112,10 +112,6 @@ class SetupController extends Controller 'site_name' => 'required', ]); - if ($request->has('generate_random')) { - $artisan->call('key:generate'); - $artisan->call('salt:random'); - } $artisan->call('jwt:secret', ['--no-interaction' => true]); $artisan->call('passport:keys', ['--no-interaction' => true]); @@ -157,8 +153,6 @@ class SetupController extends Controller $filesystem->put(storage_path('install.lock'), ''); - return view('setup.wizard.finish')->with([ - 'email' => $request->input('email'), - ]); + return view('setup.wizard.finish'); } } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 3dcff87f..4e504111 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -61,7 +61,10 @@ class UserController extends Controller ], 'widgets' => [ [ - ['user.widgets.dashboard.usage'], + [ + 'user.widgets.email-verification', + 'user.widgets.dashboard.usage', + ], ['user.widgets.dashboard.announcement'], ], ], @@ -100,8 +103,8 @@ class UserController extends Controller 'players' => $this->calculatePercentageUsed($user->players->count(), option('score_per_player')), 'storage' => $this->calculatePercentageUsed($this->getStorageUsed($user), option('score_per_storage')), ], - 'signAfterZero' => option('sign_after_zero'), - 'signGapTime' => option('sign_gap_time'), + 'signAfterZero' => (bool) option('sign_after_zero'), + 'signGapTime' => (int) option('sign_gap_time'), ]); } diff --git a/app/Http/View/Composers/LanguagesMenuComposer.php b/app/Http/View/Composers/LanguagesMenuComposer.php index 3bbfed92..e7b030e3 100644 --- a/app/Http/View/Composers/LanguagesMenuComposer.php +++ b/app/Http/View/Composers/LanguagesMenuComposer.php @@ -32,8 +32,10 @@ class LanguagesMenuComposer return $locale; }); + $current = 'locales.'.app()->getLocale(); $view->with([ - 'current' => config('locales.'.app()->getLocale().'.short_name'), + 'lang_short_name' => config($current.'.short_name'), + 'lang_name' => config($current.'.name'), 'langs' => $langs, ]); } diff --git a/app/Providers/ViewServiceProvider.php b/app/Providers/ViewServiceProvider.php index 5203f08d..7eb2c09e 100644 --- a/app/Providers/ViewServiceProvider.php +++ b/app/Providers/ViewServiceProvider.php @@ -30,14 +30,19 @@ class ViewServiceProvider extends ServiceProvider View::composer('shared.head', Composers\HeadComposer::class); View::composer('shared.notifications', function ($view) { - $notifications = auth()->user()->unreadNotifications; - $view->with([ - 'notifications' => $notifications, - 'amount' => count($notifications), - ]); + $notifications = auth()->user()->unreadNotifications->map(function ($notification) { + return [ + 'id' => $notification->id, + 'title' => $notification->data['title'], + ]; + }); + $view->with(['notifications' => $notifications]); }); - View::composer('shared.languages', Composers\LanguagesMenuComposer::class); + View::composer( + ['shared.languages', 'errors.*'], + Composers\LanguagesMenuComposer::class + ); View::composer('shared.user-menu', Composers\UserMenuComposer::class); @@ -72,11 +77,9 @@ class ViewServiceProvider extends ServiceProvider View::composer(['errors.*', 'setup.*'], function ($view) use ($webpack) { $view->with([ 'styles' => [ - $webpack->url('setup.css'), - ], - 'scripts' => [ - $webpack->url('language-chooser.js'), + $webpack->url('spectre.css'), ], + 'scripts' => [], ]); }); diff --git a/package.json b/package.json index dad98a42..8cd68a35 100644 --- a/package.json +++ b/package.json @@ -11,19 +11,26 @@ "private": true, "scripts": { "dev": "webpack-dev-server", - "build": "webpack --mode=production -p", + "build": "webpack --mode=production -p --progress", "lint": "eslint --ext=js,vue,ts -f=beauty .", "test": "jest" }, "dependencies": { "@babel/runtime": "^7.8.0", "@fortawesome/fontawesome-free": "^5.12.0", + "@hot-loader/react-dom": "^16.11.0", "@tweenjs/tween.js": "^18.4.2", "admin-lte": "^3.0.1", "echarts": "^4.6.0", "jquery": "^3.4.1", + "lodash.debounce": "^4.0.8", + "nanoid": "^2.1.11", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "react-hot-loader": "^4.12.18", "rxjs": "^6.5.3", "skinview3d": "^1.2.1", + "spectre.css": "^0.5.8", "vue": "^2.6.11", "vue-good-table": "^2.18.1", "vue-recaptcha": "^1.2.0", @@ -35,10 +42,18 @@ "@babel/plugin-transform-runtime": "^7.8.3", "@babel/preset-env": "^7.8.2", "@gplane/tsconfig": "^1.0.0", + "@testing-library/jest-dom": "^5.0.2", + "@testing-library/react": "^9.4.0", "@types/bootstrap": "^4.3.1", "@types/echarts": "^4.4.2", "@types/jest": "^24.0.25", "@types/jquery": "^3.3.29", + "@types/js-yaml": "^3.12.2", + "@types/lodash.debounce": "^4.0.6", + "@types/nanoid": "^2.1.0", + "@types/react": "^16.9.17", + "@types/react-dom": "^16.9.4", + "@types/tween.js": "^17.2.0", "@types/webpack": "^4.41.2", "@typescript-eslint/eslint-plugin": "^2.8.0", "@typescript-eslint/parser": "^2.8.0", @@ -56,9 +71,12 @@ "file-loader": "^5.0.2", "jest": "^24.9.0", "jest-extended": "^0.11.2", + "js-yaml": "^3.13.1", "mini-css-extract-plugin": "^0.9.0", "optimize-css-assets-webpack-plugin": "^5.0.3", "postcss-loader": "^3.0.0", + "sass": "^1.25.0", + "sass-loader": "^8.0.2", "style-loader": "^1.1.2", "stylus": "^0.54.7", "stylus-loader": "^3.0.2", @@ -72,8 +90,7 @@ "webpack": "^4.41.2", "webpack-cli": "^3.3.10", "webpack-dev-server": "^3.10.1", - "webpack-manifest-plugin": "^2.2.0", - "webpackbar": "^4.0.0" + "webpack-manifest-plugin": "^2.2.0" }, "resolutions": { "caniuse-lite": "*" @@ -186,14 +203,15 @@ }, "moduleFileExtensions": [ "js", - "vue", "ts", + "tsx", + "vue", "json", "node" ], "moduleNameMapper": { "^@/(.*)$": "/resources/assets/src/$1", - "\\.css$": "/resources/assets/tests/__mocks__/style.ts", + "\\.(s?css|styl)$": "/resources/assets/tests/__mocks__/style.ts", "\\.(png|jpg)$": "/resources/assets/tests/__mocks__/file.ts" }, "setupFilesAfterEnv": [ @@ -204,6 +222,15 @@ "/resources/assets/tests/setup", "/resources/assets/tests/utils" ], - "testRegex": "resources/assets/tests/.*\\.(spec|test)\\.(t|j)s$" + "testMatch": [ + "/resources/assets/tests/**/*.test.ts", + "/resources/assets/tests/**/*.test.tsx" + ], + "globals": { + "ts-jest": { + "tsConfig": "/resources/assets/tests/tsconfig.json", + "isolatedModules": true + } + } } } diff --git a/resources/assets/src/components/AddPlayerDialog.vue b/resources/assets/src/components/AddPlayerDialog.vue deleted file mode 100644 index 0f2e8aa1..00000000 --- a/resources/assets/src/components/AddPlayerDialog.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/resources/assets/src/components/ButtonEdit.tsx b/resources/assets/src/components/ButtonEdit.tsx new file mode 100644 index 00000000..5c470f94 --- /dev/null +++ b/resources/assets/src/components/ButtonEdit.tsx @@ -0,0 +1,14 @@ +import React from 'react' + +interface Props { + title?: string + onClick: React.MouseEventHandler +} + +const ButtonEdit: React.FC = props => ( + + + +) + +export default ButtonEdit diff --git a/resources/assets/src/components/ClosetItem.vue b/resources/assets/src/components/ClosetItem.vue deleted file mode 100644 index 3182c276..00000000 --- a/resources/assets/src/components/ClosetItem.vue +++ /dev/null @@ -1,130 +0,0 @@ - - - - - diff --git a/resources/assets/src/components/EmailVerification.vue b/resources/assets/src/components/EmailVerification.vue deleted file mode 100644 index 9617aa2d..00000000 --- a/resources/assets/src/components/EmailVerification.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/resources/assets/src/components/Loading.tsx b/resources/assets/src/components/Loading.tsx new file mode 100644 index 00000000..980ba246 --- /dev/null +++ b/resources/assets/src/components/Loading.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +const Loading = () => ( +
+ +
+) + +export default Loading diff --git a/resources/assets/src/components/Modal.tsx b/resources/assets/src/components/Modal.tsx new file mode 100644 index 00000000..a37118e3 --- /dev/null +++ b/resources/assets/src/components/Modal.tsx @@ -0,0 +1,175 @@ +import React, { useState, useEffect, useRef } from 'react' +import $ from 'jquery' +import 'bootstrap' +import { trans } from '../scripts/i18n' +import ModalHeader from './ModalHeader' +import ModalBody from './ModalBody' +import ModalFooter from './ModalFooter' + +export type ModalOptions = { + mode?: 'alert' | 'confirm' | 'prompt' + show?: boolean + title?: string + text?: string + dangerousHTML?: string + input?: string + placeholder?: string + inputType?: string + validator?(value: any): string | boolean | undefined + choices?: { text: string; value: string }[] + type?: string + showHeader?: boolean + center?: boolean + okButtonText?: string + okButtonType?: string + cancelButtonText?: string + cancelButtonType?: string + flexFooter?: boolean + children?: React.ReactNode +} + +type Props = { + id?: string + children?: React.ReactNode + footer?: React.ReactNode + onConfirm?(payload: { value: string }): void + onDismiss?(): void + onClose?(): void +} + +export type ModalResult = { + value: string +} + +const Modal: React.FC = props => { + const [value, setValue] = useState(props.input!) + const [valid, setValid] = useState(true) + const [validatorMessage, setValidatorMessage] = useState('') + const ref = useRef(null) + + const { show } = props + + useEffect(() => { + if (!show) { + return + } + + const onHidden = () => props.onClose?.() + + const el = $(ref.current!) + el.on('hidden.bs.modal', onHidden) + + return () => { + el.off('hidden.bs.modal', onHidden) + } + }, [show, props.onClose]) + + const handleInputChange = (event: React.ChangeEvent) => { + setValue(event.target.value) + } + + const confirm = () => { + const { validator } = props + if (typeof validator === 'function') { + const result = validator(value) + if (typeof result === 'string') { + setValidatorMessage(result) + setValid(false) + return + } + } + + props.onConfirm?.({ value }) + $(ref.current!).modal('hide') + + // The "hidden.bs.modal" event can't be trigged automatically when testing. + /* istanbul ignore next */ + if (process.env.NODE_ENV === 'test') { + $(ref.current!).trigger('hidden.bs.modal') + } + } + + const dismiss = () => { + props.onDismiss?.() + $(ref.current!).modal('hide') + + /* istanbul ignore next */ + if (process.env.NODE_ENV === 'test') { + $(ref.current!).trigger('hidden.bs.modal') + } + } + + useEffect(() => { + if (show) { + setTimeout(() => $(ref.current!).modal('show'), 50) + } + }, [show]) + + if (!show) { + return null + } + + return ( + + ) +} + +Modal.defaultProps = { + mode: 'confirm', + title: trans('general.tip'), + text: '', + input: '', + placeholder: '', + inputType: 'text', + type: 'default', + showHeader: true, + center: false, + okButtonText: trans('general.confirm'), + okButtonType: 'primary', + cancelButtonText: trans('general.cancel'), + cancelButtonType: 'secondary', + flexFooter: false, +} + +export default Modal diff --git a/resources/assets/src/components/ModalBody.tsx b/resources/assets/src/components/ModalBody.tsx new file mode 100644 index 00000000..ef66893b --- /dev/null +++ b/resources/assets/src/components/ModalBody.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import ModalContent from './ModalContent' +import ModalInput from './ModalInput' + +interface Props { + text?: string + dangerousHTML?: string + showInput: boolean + inputType?: string + value?: string + choices?: { text: string; value: string }[] + onChange?: React.ChangeEventHandler + placeholder?: string + invalid?: boolean + validatorMessage?: string +} + +const ModalBody: React.FC = props => { + return ( +
+ + {props.children} + + {props.showInput && } +
+ ) +} + +export default ModalBody diff --git a/resources/assets/src/components/ModalContent.tsx b/resources/assets/src/components/ModalContent.tsx new file mode 100644 index 00000000..cb0ac8a5 --- /dev/null +++ b/resources/assets/src/components/ModalContent.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +interface Props { + text?: string + dangerousHTML?: string +} + +const ModalContent: React.FC = props => { + if (props.children) { + return <>{props.children} + } else if (props.text) { + return ( + <> + {props.text.split(/\r?\n/).map((line, i) => ( +

{line}

+ ))} + + ) + } else if (props.dangerousHTML) { + return
+ } + + return <> +} + +export default ModalContent diff --git a/resources/assets/src/components/ModalFooter.tsx b/resources/assets/src/components/ModalFooter.tsx new file mode 100644 index 00000000..dc26b81f --- /dev/null +++ b/resources/assets/src/components/ModalFooter.tsx @@ -0,0 +1,46 @@ +import React from 'react' + +interface Props { + showCancelButton: boolean + flexFooter?: boolean + okButtonText?: string + okButtonType?: string + cancelButtonText?: string + cancelButtonType?: string + onConfirm?(): void + onDismiss?(): void +} + +const ModalFooter: React.FC = props => { + const classes = ['modal-footer'] + if (props.flexFooter) { + classes.push('d-flex', 'justify-content-between') + } + const footerClass = classes.join(' ') + + return props.children ? ( +
{props.children}
+ ) : ( +
+ {props.showCancelButton && ( + + )} + +
+ ) +} + +export default ModalFooter diff --git a/resources/assets/src/components/ModalHeader.tsx b/resources/assets/src/components/ModalHeader.tsx new file mode 100644 index 00000000..b363639f --- /dev/null +++ b/resources/assets/src/components/ModalHeader.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +interface Props { + show?: boolean + title?: string + onDismiss?(): void +} + +const ModalHeader: React.FC = props => + props.show ? ( +
+
{props.title}
+ +
+ ) : null + +export default ModalHeader diff --git a/resources/assets/src/components/ModalInput.tsx b/resources/assets/src/components/ModalInput.tsx new file mode 100644 index 00000000..a0dcf4d6 --- /dev/null +++ b/resources/assets/src/components/ModalInput.tsx @@ -0,0 +1,52 @@ +import React from 'react' + +interface Props { + inputType?: string + value?: string + choices?: { text: string; value: string }[] + onChange?: React.ChangeEventHandler + placeholder?: string + invalid?: boolean + validatorMessage?: string +} + +const ModalInput: React.FC = props => ( + <> + {props.inputType === 'radios' && props.choices ? ( + <> + {props.choices.map(choice => ( +
+ + +
+ ))} + + ) : ( +
+ +
+ )} + {props.invalid && ( +
+ + {props.validatorMessage} +
+ )} + +) + +export default ModalInput diff --git a/resources/assets/src/components/Pagination.tsx b/resources/assets/src/components/Pagination.tsx new file mode 100644 index 00000000..3feadd0f --- /dev/null +++ b/resources/assets/src/components/Pagination.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import PaginationItem from './PaginationItem' + +interface Props { + page: number + totalPages: number + onChange(page: number): void | Promise +} + +export const labels = { + first: '«', + prev: '‹', + next: '›', + last: '»', +} + +const Pagination: React.FC = props => { + const { page, totalPages, onChange } = props + + if (totalPages < 1) { + return null + } + + return ( +
    + onChange(1)}> + {labels.first} + + onChange(page - 1)}> + {labels.prev} + + {totalPages < 8 ? ( + Array.from({ length: totalPages }).map((_, i) => ( + onChange(i + 1)} + > + {i + 1} + + )) + ) : page < 3 || totalPages - page < 2 ? ( + <> + {[1, 2].map(p => ( + onChange(p)} + > + {p} + + ))} + ... + {[totalPages - 1, totalPages].map(p => ( + onChange(p)} + > + {p} + + ))} + + ) : ( + <> + onChange(1)}>1 + ... + {[page - 1, page, page + 1].map(p => ( + onChange(p)} + > + {p} + + ))} + ... + onChange(totalPages)}> + {totalPages} + + + )} + onChange(page + 1)} + > + {labels.next} + + onChange(totalPages)} + > + {labels.last} + +
+ ) +} + +export default Pagination diff --git a/resources/assets/src/components/PaginationItem.tsx b/resources/assets/src/components/PaginationItem.tsx new file mode 100644 index 00000000..a240f89f --- /dev/null +++ b/resources/assets/src/components/PaginationItem.tsx @@ -0,0 +1,33 @@ +import React from 'react' + +interface Props { + disabled?: boolean + active?: boolean + onClick?(): void +} + +const PaginationItem: React.FC = props => { + const classes = ['page-item'] + if (props.active) { + classes.push('active') + } + if (props.disabled) { + classes.push('disabled') + } + + const handleClick = () => { + if (!props.disabled && props.onClick) { + props.onClick() + } + } + + return ( +
  • + + {props.children} + +
  • + ) +} + +export default PaginationItem diff --git a/resources/assets/src/components/Toast.scss b/resources/assets/src/components/Toast.scss new file mode 100644 index 00000000..c7d37f8d --- /dev/null +++ b/resources/assets/src/components/Toast.scss @@ -0,0 +1,12 @@ +.toast { + position: fixed; + right: calc((100% - 350px) / 2); + width: 350px; + z-index: 1050; + transition-property: top; + transition-duration: 0.3s; +} + +.shadow { + box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1); +} diff --git a/resources/assets/src/components/Toast.tsx b/resources/assets/src/components/Toast.tsx new file mode 100644 index 00000000..e08b8db3 --- /dev/null +++ b/resources/assets/src/components/Toast.tsx @@ -0,0 +1,69 @@ +import React, { useState, useEffect } from 'react' +import styles from './Toast.scss' + +export type ToastType = 'success' | 'info' | 'warning' | 'error' + +interface Props { + type: ToastType + distance: number + onClose(): void | Promise +} + +const icons = new Map([ + ['success', 'check'], + ['info', 'info'], + ['warning', 'exclamation-triangle'], + ['error', 'times-circle'], +]) + +const Toast: React.FC = props => { + const [show, setShow] = useState(false) + + useEffect(() => { + const id1 = setTimeout(() => setShow(true), 100) + const id2 = setTimeout(() => setShow(false), 3000) + const id3 = setTimeout(props.onClose, 3100) + + return () => { + clearTimeout(id1) + clearTimeout(id2) + clearTimeout(id3) + } + }, [props.onClose]) + + const type = props.type === 'error' ? 'danger' : props.type + + const classes = [ + `alert alert-${type}`, + 'd-flex justify-content-between', + 'fade', + styles.shadow, + ] + if (show) { + classes.push('show') + } + + const role = type === 'success' || type === 'info' ? 'status' : 'alert' + + return ( +
    +
    + + + {props.children} + + +
    +
    + ) +} + +Toast.displayName = 'Toast' + +export default Toast diff --git a/resources/assets/src/components/Viewer.scss b/resources/assets/src/components/Viewer.scss new file mode 100644 index 00000000..93327b26 --- /dev/null +++ b/resources/assets/src/components/Viewer.scss @@ -0,0 +1,20 @@ +@use '../styles/breakpoints'; + +.viewer { + @include breakpoints.greater-than('lg') { + min-height: 500px; + } + + canvas { + cursor: move; + } +} + +.actions i { + display: inline; + padding: 0.5em 0.5em; + &:hover { + color: #555; + cursor: pointer; + } +} diff --git a/resources/assets/src/components/Viewer.tsx b/resources/assets/src/components/Viewer.tsx new file mode 100644 index 00000000..faaa17ba --- /dev/null +++ b/resources/assets/src/components/Viewer.tsx @@ -0,0 +1,192 @@ +import React, { useState, useEffect, useRef } from 'react' +import * as skinview3d from 'skinview3d' +import { trans } from '../scripts/i18n' +import styles from './Viewer.scss' +import SkinSteve from '../../../misc/textures/steve.png' + +interface Props { + skin?: string + cape?: string + isAlex?: boolean + showIndicator?: boolean + initPositionZ?: number +} + +type ViewerStuff = { + handles: { + walk: skinview3d.AnimationHandle + run: skinview3d.AnimationHandle + rotate: skinview3d.AnimationHandle + } + control: skinview3d.OrbitControls + firstRun: boolean +} + +const emptyStuff: ViewerStuff = { + handles: { + walk: {} as skinview3d.AnimationHandle, + run: {} as skinview3d.AnimationHandle, + rotate: {} as skinview3d.AnimationHandle, + }, + control: {} as skinview3d.OrbitControls, + firstRun: true, +} + +const Viewer: React.FC = props => { + const viewRef: React.MutableRefObject = useRef(null!) + const containerRef = useRef(null) + const stuffRef = useRef(emptyStuff) + + const [paused, setPaused] = useState(false) + const [reset, setReset] = useState(0) + const indicator = (() => { + const { skin, cape } = props + if (skin && cape) { + return `${trans('general.skin')} & ${trans('general.cape')}` + } else if (skin) { + return trans('general.skin') + } else if (cape) { + return trans('general.cape') + } + return '' + })() + + useEffect(() => { + const container = containerRef.current! + const viewer = new skinview3d.SkinViewer({ + domElement: container, + width: container.clientWidth, + height: container.clientHeight, + skinUrl: props.skin || SkinSteve, + capeUrl: props.cape || '', + detectModel: false, + }) + viewer.camera.position.z = props.initPositionZ! + + const animation = new skinview3d.CompositeAnimation() + stuffRef.current.handles = { + walk: animation.add(skinview3d.WalkingAnimation), + run: animation.add(skinview3d.RunningAnimation), + rotate: animation.add(skinview3d.RotatingAnimation), + } + stuffRef.current.handles.run.paused = true + // @ts-ignore + viewer.animation = animation as skinview3d.Animation + stuffRef.current.control = skinview3d.createOrbitControls(viewer) + + if (!stuffRef.current.firstRun) { + const { handles } = stuffRef.current + handles.walk.paused = true + handles.run.paused = true + handles.rotate.paused = true + viewer.camera.position.z = 70 + } + + viewRef.current = viewer + + return () => { + viewer.dispose() + stuffRef.current.firstRun = false + } + }, [reset]) + + useEffect(() => { + return () => { + stuffRef.current.firstRun = true + } + }, []) + + useEffect(() => { + const viewer = viewRef.current + viewer.skinUrl = props.skin || SkinSteve + }, [props.skin]) + + useEffect(() => { + const viewer = viewRef.current + if (props.cape) { + viewer.capeUrl = props.cape + } else { + viewer.playerObject.cape.visible = false + } + }, [props.cape]) + + useEffect(() => { + const viewer = viewRef.current + viewer.playerObject.skin.slim = !!props.isAlex + }, [props.isAlex]) + + const togglePause = () => { + setPaused(paused => !paused) + viewRef.current.animationPaused = !viewRef.current.animationPaused + } + + const toggleRun = () => { + const { handles } = stuffRef.current + handles.run.paused = !handles.run.paused + handles.walk.paused = false + } + + const toggleRotate = () => { + const { handles } = stuffRef.current + handles.rotate.paused = !handles.rotate.paused + } + + const handleReset = () => { + setReset(c => c + 1) + } + + return ( +
    +
    +
    +

    + {trans('general.texturePreview')} + {props.showIndicator && ( + {indicator} + )} +

    +
    + + + + +
    +
    +
    +
    +
    +
    + {props.children &&
    {props.children}
    } +
    + ) +} + +Viewer.defaultProps = { + initPositionZ: 70, +} + +export default Viewer diff --git a/resources/assets/src/components/ViewerSkeleton.tsx b/resources/assets/src/components/ViewerSkeleton.tsx new file mode 100644 index 00000000..d6d5c357 --- /dev/null +++ b/resources/assets/src/components/ViewerSkeleton.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { trans } from '@/scripts/i18n' + +const ViewerSkeleton: React.FC = () => ( +
    +
    +
    +

    + {trans('general.texturePreview')} +

    +
    +
    +
    +
    +) + +export default ViewerSkeleton diff --git a/resources/assets/src/components/mixins/addClosetItem.ts b/resources/assets/src/components/mixins/addClosetItem.ts index 6a0b8185..603727dc 100644 --- a/resources/assets/src/components/mixins/addClosetItem.ts +++ b/resources/assets/src/components/mixins/addClosetItem.ts @@ -26,10 +26,10 @@ export default Vue.extend<{ { tid: this.tid, name: value }, ) if (code === 0) { - toast.success(message) + toast.success(message!) this.$emit('like-toggled', true) } else { - toast.error(message) + toast.error(message!) } }, }, diff --git a/resources/assets/src/components/mixins/removeClosetItem.ts b/resources/assets/src/components/mixins/removeClosetItem.ts index 82016eeb..a17b8de4 100644 --- a/resources/assets/src/components/mixins/removeClosetItem.ts +++ b/resources/assets/src/components/mixins/removeClosetItem.ts @@ -19,9 +19,9 @@ export default Vue.extend<{ const { code, message } = await this.$http.post(`/user/closet/remove/${this.tid}`) if (code === 0) { this.$emit('item-removed') - toast.success(message) + toast.success(message!) } else { - toast.error(message) + toast.error(message!) } }, }, diff --git a/resources/assets/src/components/mixins/setAsAvatar.ts b/resources/assets/src/components/mixins/setAsAvatar.ts index 4af8b9ae..14b8d8a2 100644 --- a/resources/assets/src/components/mixins/setAsAvatar.ts +++ b/resources/assets/src/components/mixins/setAsAvatar.ts @@ -20,7 +20,7 @@ export default Vue.extend<{ { tid: this.tid }, ) if (code === 0) { - toast.success(message) + toast.success(message!) Array .from( @@ -28,7 +28,7 @@ export default Vue.extend<{ ) .forEach(el => (el.src += `?${new Date().getTime()}`)) } else { - toast.error(message) + toast.error(message!) } }, }, diff --git a/resources/assets/src/fonts/minecraft.css b/resources/assets/src/fonts/minecraft.css new file mode 100644 index 00000000..cdfb0034 --- /dev/null +++ b/resources/assets/src/fonts/minecraft.css @@ -0,0 +1,5 @@ +@font-face { + font-family: Minecraft; + src: url('./minecraft.woff2') format('woff2'), + url('./minecraft.woff') format('woff'); +} diff --git a/resources/assets/src/index.ts b/resources/assets/src/index.ts deleted file mode 100644 index c3c5e8c1..00000000 --- a/resources/assets/src/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import Vue from 'vue' -import './scripts/app' -import routes from './scripts/route' - -Vue.config.productionTip = false - -loadModules() - -function loadModules() { - const route = routes.find( - // eslint-disable-next-line no-shadow - route => new RegExp(`^${route.path}$`, 'i').test(blessing.route), - ) - if (route) { - if (route.module) { - Promise.all(route.module.map(m => m())) - } - if (route.component) { - Vue.prototype.$route = new RegExp(`^${route.path}$`, 'i').exec(blessing.route) - // eslint-disable-next-line no-new - new Vue({ - el: route.el, - render: h => h(route.component), - }) - } - } -} diff --git a/resources/assets/src/index.tsx b/resources/assets/src/index.tsx new file mode 100644 index 00000000..4a3d822a --- /dev/null +++ b/resources/assets/src/index.tsx @@ -0,0 +1,49 @@ +import Vue from 'vue' +import * as React from 'react' +import ReactDOM from 'react-dom' +import './scripts/app' +import routes from './scripts/route' +import * as emitter from './scripts/event' + +Vue.config.productionTip = false + +loadModules() + +function loadModules() { + const route = routes.find( + // eslint-disable-next-line no-shadow + route => new RegExp(`^${route.path}$`, 'i').test(blessing.route), + ) + if (route) { + if (route.module) { + Promise.all(route.module.map(m => m())) + } + if (route.react) { + const Component = React.lazy( + route.react as () => Promise<{ default: React.ComponentType }>, + ) + const Root = () => ( + + + + ) + const c = + typeof route.el === 'string' + ? document.querySelector(route.el) + : route.el + ReactDOM.render(, c, () => { + emitter.emit('mounted', { el: route.el }) + }) + } + if (route.component) { + Vue.prototype.$route = new RegExp(`^${route.path}$`, 'i').exec( + blessing.route, + ) + // eslint-disable-next-line no-new + new Vue({ + el: route.el, + render: h => h(route.component), + }) + } + } +} diff --git a/resources/assets/src/scripts/app.ts b/resources/assets/src/scripts/app.ts index 5128438b..69c7cb93 100644 --- a/resources/assets/src/scripts/app.ts +++ b/resources/assets/src/scripts/app.ts @@ -5,6 +5,7 @@ import './i18n' import './net' import './event' import './notification' +import './emailVerification' import './logout' window.addEventListener('load', () => { diff --git a/resources/assets/src/scripts/emailVerification.tsx b/resources/assets/src/scripts/emailVerification.tsx new file mode 100644 index 00000000..2389fa59 --- /dev/null +++ b/resources/assets/src/scripts/emailVerification.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import EmailVerification from '@/views/widgets/EmailVerification' + +const container = document.querySelector('#email-verification') + +if (blessing.extra.unverified && container) { + ReactDOM.render(, container) +} diff --git a/resources/assets/src/scripts/event.ts b/resources/assets/src/scripts/event.ts index dabcf405..5a724977 100644 --- a/resources/assets/src/scripts/event.ts +++ b/resources/assets/src/scripts/event.ts @@ -1,12 +1,20 @@ /* eslint-disable @typescript-eslint/no-unnecessary-condition */ -const bus: { [name: string]: CallableFunction[] } = Object.create(null) +const bus = new Map>() -export function on(eventName: string, listener: CallableFunction) { - (bus[eventName] || (bus[eventName] = [])).push(listener) +export function on(event: string | symbol, listener: CallableFunction) { + if (!bus.has(event)) { + bus.set(event, new Set()) + } + const listeners = bus.get(event)! + listeners.add(listener) + + return () => { + listeners.delete(listener) + } } -export function emit(eventName: string, payload?: any) { - bus[eventName] && bus[eventName].forEach(listener => listener(payload)) +export function emit(event: string | symbol, payload?: unknown) { + bus.get(event)?.forEach(listener => listener(payload)) } blessing.event = { on, emit } diff --git a/resources/assets/src/scripts/hooks/useTexture.ts b/resources/assets/src/scripts/hooks/useTexture.ts new file mode 100644 index 00000000..8801ec9a --- /dev/null +++ b/resources/assets/src/scripts/hooks/useTexture.ts @@ -0,0 +1,33 @@ +import { useState, useEffect } from 'react' +import * as fetch from '../net' +import { Texture, TextureType } from '../types' + +type Response = fetch.ResponseBody + +export default function useTexture(): [ + { url: string; type: TextureType }, + React.Dispatch>, +] { + const [tid, setTid] = useState(0) + const [url, setUrl] = useState('') + const [type, setType] = useState('steve') + + useEffect(() => { + if (tid <= 0) { + setUrl('') + return + } + + const getTexture = async () => { + const { + data: { hash, type }, + } = await fetch.get(`/skinlib/info/${tid}`) + + setUrl(`${blessing.base_url}/textures/${hash}`) + setType(type) + } + getTexture() + }, [tid]) + + return [{ url, type }, setTid] +} diff --git a/resources/assets/src/scripts/hooks/useTween.ts b/resources/assets/src/scripts/hooks/useTween.ts new file mode 100644 index 00000000..c9c961f7 --- /dev/null +++ b/resources/assets/src/scripts/hooks/useTween.ts @@ -0,0 +1,24 @@ +import React, { useState, useEffect, useRef } from 'react' +import TWEEN from '@tweenjs/tween.js' + +export default function useTween( + initialValue: T, +): [T, React.Dispatch>] { + const [value, setValue] = useState(initialValue) + const ref = useRef(value) + const [dest, setDest] = useState(initialValue) + + useEffect(() => { + function animate() { + requestAnimationFrame(animate) + TWEEN.update() + setValue(ref.current) + } + + const tween = new TWEEN.Tween(ref) + tween.to({ current: dest }, 1000).start() + animate() + }, [dest]) + + return [value, setDest] +} diff --git a/resources/assets/src/scripts/i18n.ts b/resources/assets/src/scripts/i18n.ts index 47bfea27..71d2d1c7 100644 --- a/resources/assets/src/scripts/i18n.ts +++ b/resources/assets/src/scripts/i18n.ts @@ -25,6 +25,8 @@ export function trans(key: string, parameters = Object.create(null)): string { return result } +export const t = trans + Vue.use(_Vue => { // eslint-disable-next-line @typescript-eslint/unbound-method _Vue.prototype.$t = trans @@ -44,5 +46,4 @@ Vue.use(_Vue => { }) }) -// eslint-disable-next-line @typescript-eslint/unbound-method -window.trans = trans +Object.assign(window, { trans }) diff --git a/resources/assets/src/scripts/language-chooser.ts b/resources/assets/src/scripts/language-chooser.ts deleted file mode 100644 index a25bf077..00000000 --- a/resources/assets/src/scripts/language-chooser.ts +++ /dev/null @@ -1,4 +0,0 @@ -const chooser = document.querySelector('#language-chooser') -chooser?.addEventListener('change', () => { - window.location.href = `?lang=${chooser.value}` -}) diff --git a/resources/assets/src/scripts/modal.ts b/resources/assets/src/scripts/modal.ts deleted file mode 100644 index c74e94c8..00000000 --- a/resources/assets/src/scripts/modal.ts +++ /dev/null @@ -1,51 +0,0 @@ -import $ from 'jquery' -import 'bootstrap' -import Vue from 'vue' -import Modal from '../components/Modal.vue' - -export interface ModalOptions { - mode?: 'alert' | 'confirm' | 'prompt' - title?: string - text?: string - dangerousHTML?: string - input?: string - placeholder?: string - inputType?: string - validator?(value: any): string | boolean | void - type?: string - showHeader?: boolean - center?: boolean - okButtonText?: string - okButtonType?: string - cancelButtonText?: string - cancelButtonType?: string - flexFooter?: boolean -} - -export interface ModalResult { - value: string -} - -export function showModal(options: ModalOptions = {}): Promise { - return new Promise((resolve, reject) => { - const container = document.createElement('div') - document.body.appendChild(container) - - const instance = new Vue({ - render: h => h(Modal, { - props: Object.assign({ center: true }, options), - on: { - confirm: resolve, - dismiss: reject, - }, - }), - }).$mount(container) - - $(instance.$el) - .modal('show') - .on('hidden.bs.modal', () => { - instance.$el.remove() - instance.$destroy() - }) - }) -} diff --git a/resources/assets/src/scripts/modal.tsx b/resources/assets/src/scripts/modal.tsx new file mode 100644 index 00000000..6d0747dc --- /dev/null +++ b/resources/assets/src/scripts/modal.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import Modal, { ModalOptions, ModalResult } from '../components/Modal' + +export function showModal(options: ModalOptions = {}): Promise { + return new Promise((resolve, reject) => { + const container = document.createElement('div') + document.body.appendChild(container) + + const handleClose = () => { + ReactDOM.unmountComponentAtNode(container) + document.body.removeChild(container) + } + + ReactDOM.render( + , + container, + ) + }) +} diff --git a/resources/assets/src/scripts/net.ts b/resources/assets/src/scripts/net.ts index 7ec61a58..2c70b8a0 100644 --- a/resources/assets/src/scripts/net.ts +++ b/resources/assets/src/scripts/net.ts @@ -4,9 +4,10 @@ import { queryStringify } from './utils' import { showModal } from './notify' import { trans } from './i18n' -export interface ResponseBody { +export interface ResponseBody { code: number message: string + data: T extends null ? never : T } class HTTPError extends Error { @@ -27,8 +28,9 @@ export const init: RequestInit = { } function retrieveToken() { - const csrfField = - document.querySelector('meta[name="csrf-token"]') + const csrfField = document.querySelector( + 'meta[name="csrf-token"]', + ) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return csrfField?.content || '' } @@ -39,9 +41,10 @@ export async function walkFetch(request: Request): Promise { try { const response = await fetch(request) const cloned = response.clone() - const body = response.headers.get('Content-Type') === 'application/json' - ? await response.json() - : await response.text() + const body = + response.headers.get('Content-Type') === 'application/json' + ? await response.json() + : await response.text() if (response.ok) { return body } @@ -49,7 +52,9 @@ export async function walkFetch(request: Request): Promise { if (response.status === 422) { // Process validation errors from Laravel. - const { errors }: { + const { + errors, + }: { message: string errors: { [field: string]: string[] } } = body @@ -67,7 +72,7 @@ export async function walkFetch(request: Request): Promise { } if (body.exception && Array.isArray(body.trace)) { - const trace = (body.trace as Array<{ file: string, line: number }>) + const trace = (body.trace as Array<{ file: string; line: number }>) .map((t, i) => `[${i + 1}] ${t.file}#L${t.line}`) .join('
    ') message = `${message}
    ${trace}
    ` @@ -86,7 +91,7 @@ export async function walkFetch(request: Request): Promise { } } -export function get(url: string, params = empty): Promise { +export function get(url: string, params = empty): Promise { emit('beforeFetch', { method: 'GET', url, @@ -95,10 +100,12 @@ export function get(url: string, params = empty): Promise { const qs = queryStringify(params) - return walkFetch(new Request(`${blessing.base_url}${url}${qs && `?${qs}`}`, init)) + return walkFetch( + new Request(`${blessing.base_url}${url}${qs && `?${qs}`}`, init), + ) } -function nonGet(method: string, url: string, data: any): Promise { +function nonGet(method: string, url: string, data: any): Promise { emit('beforeFetch', { method: method.toUpperCase(), url, @@ -118,26 +125,32 @@ function nonGet(method: string, url: string, data: any): Promise { return walkFetch(request) } -export function post(url: string, data = empty): Promise { - return nonGet('POST', url, data) +export function post(url: string, data = empty): Promise { + return nonGet('POST', url, data) } -export function put(url: string, data = empty): Promise { - return nonGet('PUT', url, data) +export function put(url: string, data = empty): Promise { + return nonGet('PUT', url, data) } -export function del(url: string, data = empty): Promise { - return nonGet('DELETE', url, data) +export function del(url: string, data = empty): Promise { + return nonGet('DELETE', url, data) } Vue.use(_Vue => { Object.defineProperty(_Vue.prototype, '$http', { get: () => ({ - get, post, put, del, + get, + post, + put, + del, }), }) }) blessing.fetch = { - get, post, put, del, + get, + post, + put, + del, } diff --git a/resources/assets/src/scripts/notification.ts b/resources/assets/src/scripts/notification.ts deleted file mode 100644 index 38599406..00000000 --- a/resources/assets/src/scripts/notification.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { get } from './net' -import { showModal } from './notify' - -export default async function handler(event: Event) { - const item = event.target as HTMLAnchorElement - const id = item.getAttribute('data-nid') - const { - title, content, time, - }: { - title: string - content: string - time: string - } = await get(`/user/notifications/${id!}`) - showModal({ - mode: 'alert', - title, - dangerousHTML: `${content}
    ${time}`, - }) - item.remove() - const counter = document - .querySelector('.notifications-counter') as HTMLSpanElement - const value = Number.parseInt(counter.textContent!) - 1 - if (value > 0) { - counter.textContent = value.toString() - } else { - counter.remove() - } -} - -const el = document.querySelector('.notifications-list') -// istanbul ignore next -if (el) { - el.addEventListener('click', handler) -} diff --git a/resources/assets/src/scripts/notification.tsx b/resources/assets/src/scripts/notification.tsx new file mode 100644 index 00000000..e807c4e1 --- /dev/null +++ b/resources/assets/src/scripts/notification.tsx @@ -0,0 +1,8 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import NotificationsList from '@/views/widgets/NotificationsList' + +const container = document.querySelector('[data-notifications]') +if (container) { + ReactDOM.render(, container) +} diff --git a/resources/assets/src/scripts/notify.ts b/resources/assets/src/scripts/notify.ts index f7f3d82f..d7f90f57 100644 --- a/resources/assets/src/scripts/notify.ts +++ b/resources/assets/src/scripts/notify.ts @@ -2,6 +2,14 @@ import { showModal } from './modal' import { Toast } from './toast' export const toast = new Toast() + +/* istanbul ignore next */ +if (process.env.NODE_ENV === 'test') { + afterEach(() => { + toast.clear() + }) +} + Object.assign(blessing, { notify: { showModal, toast } }) export { showModal } from './modal' diff --git a/resources/assets/src/scripts/route.ts b/resources/assets/src/scripts/route.tsx similarity index 64% rename from resources/assets/src/scripts/route.ts rename to resources/assets/src/scripts/route.tsx index 2994ed15..29840ac8 100644 --- a/resources/assets/src/scripts/route.ts +++ b/resources/assets/src/scripts/route.tsx @@ -1,54 +1,57 @@ +import React from 'react' + const virtual = document.createElement('div') export default [ { path: '/', - module: [ - () => import('../styles/home.styl'), - () => import('./home-page'), - ], + module: [() => import('../styles/home.styl'), () => import('./home-page')], }, { path: 'user', - component: () => import('../views/user/Dashboard.vue'), + react: () => import('../views/user/Dashboard'), el: '#usage-box', + frame: () => ( +
    +
     
    +
    +
     
    +
    + ), }, { path: 'user/closet', - component: () => import('../views/user/Closet.vue'), - el: virtual, + react: () => import('../views/user/Closet'), + el: '#closet-list', }, { path: 'user/player', - component: () => import('../views/user/Players.vue'), - el: virtual, + react: () => import('../views/user/Players'), + el: '#players-list', + frame: () => ( +
    +
     
    +
    +
    + ), }, { path: 'user/player/bind', - component: () => import('../views/user/Bind.vue'), + react: () => import('../views/user/BindPlayers'), el: 'form', }, - { - path: 'user/reports', - component: () => import('../views/user/Report.vue'), - el: '.content > .container-fluid', - }, { path: 'user/profile', - module: [ - () => import('../views/user/profile/index'), - ], + module: [() => import('../views/user/profile/index')], }, { path: 'user/oauth/manage', - component: () => import('../views/user/OAuth.vue'), + react: () => import('../views/user/OAuth'), el: '.content > .container-fluid', }, { path: 'admin', - module: [ - () => import('../views/admin/Dashboard'), - ], + module: [() => import('../views/admin/Dashboard')], }, { path: 'admin/users', @@ -67,9 +70,7 @@ export default [ }, { path: 'admin/customize', - module: [ - () => import('../views/admin/Customization'), - ], + module: [() => import('../views/admin/Customization')], }, { path: 'admin/i18n', @@ -78,7 +79,7 @@ export default [ }, { path: 'admin/plugins/manage', - component: () => import('../views/admin/Plugins.vue'), + react: () => import('../views/admin/PluginsManagement'), el: '.content > .container-fluid', }, { @@ -88,9 +89,7 @@ export default [ }, { path: 'admin/update', - module: [ - () => import('../views/admin/Update'), - ], + module: [() => import('../views/admin/Update')], }, { path: 'auth/login', diff --git a/resources/assets/src/scripts/toast.ts b/resources/assets/src/scripts/toast.ts deleted file mode 100644 index 328a6cc4..00000000 --- a/resources/assets/src/scripts/toast.ts +++ /dev/null @@ -1,96 +0,0 @@ -import $ from 'jquery' -import 'bootstrap' - -const toastIcons: Record = { - success: 'check', - info: 'info', - warning: 'exclamation-triangle', - error: 'times-circle', -} - -export type ToastQueue = QueueElement[] -type QueueElement = { el: HTMLDivElement, height: number } -type ToastType = - | 'success' - | 'info' - | 'warning' - | 'error' - -export function showToast( - queue: ToastQueue, - type: ToastType, - message = '', -): void { - const alertType = type === 'error' ? 'danger' : type - - const container = document.createElement('div') - container.className = 'alert-toast' - const last = queue[queue.length - 1] - if (last) { - container.style.top = `${last.el.offsetTop + last.el.offsetHeight + 12}px` - } else { - container.style.top = '35px' - } - - const toast = document.createElement('div') - toast.className = `alert alert-${alertType} d-flex justify-content-between fade` - - const icon = document.createElement('i') - icon.className = `icon fas fa-${toastIcons[type]}` - - const text = document.createElement('span') - text.textContent = message - - const span = document.createElement('span') - span.className = 'mr-auto' - span.appendChild(icon) - span.appendChild(text) - toast.appendChild(span) - - const button = document.createElement('button') - button.type = 'button' - button.className = 'ml-2 mr-1 close' - button.dataset.dismiss = 'alert' - button.textContent = '×' - toast.appendChild(button) - - container.appendChild(toast) - document.body.appendChild(container) - queue.push({ el: container, height: container.offsetHeight }) - setTimeout(() => toast.classList.add('show'), 100) - - setTimeout(() => $(toast).alert('close'), 3000) - $(toast).on('closed.bs.alert', () => { - container.remove() - let i = queue.findIndex(({ el }) => el === container) - const distance = queue[i].height + 12 - for (i += 1; i < queue.length; i += 1) { - const element = queue[i].el - element.style.top = `${element.offsetTop - distance}px` - } - }) -} - -export class Toast { - private queue: ToastQueue - - constructor() { - this.queue = [] - } - - success(message = '') { - return showToast(this.queue, 'success', message) - } - - info(message = '') { - return showToast(this.queue, 'info', message) - } - - warning(message = '') { - return showToast(this.queue, 'warning', message) - } - - error(message = '') { - return showToast(this.queue, 'error', message) - } -} diff --git a/resources/assets/src/scripts/toast.tsx b/resources/assets/src/scripts/toast.tsx new file mode 100644 index 00000000..6e99084d --- /dev/null +++ b/resources/assets/src/scripts/toast.tsx @@ -0,0 +1,78 @@ +import React, { useState, useEffect } from 'react' +import ReactDOM from 'react-dom' +import nanoid from 'nanoid' +import * as emitter from './event' +import ToastBox, { ToastType } from '../components/Toast' + +type QueueElement = { id: string; type: ToastType; message: string } +type ToastQueue = QueueElement[] + +const TOAST_EVENT = Symbol('toast') +const CLEAR_EVENT = Symbol('clear') + +export const ToastContainer: React.FC = () => { + const [queue, setQueue] = useState([]) + + useEffect(() => { + const off1 = emitter.on(TOAST_EVENT, (toast: QueueElement) => { + setQueue(queue => { + queue.push(toast) + return queue.slice() + }) + }) + const off2 = emitter.on(CLEAR_EVENT, () => setQueue([])) + + return () => { + off1() + off2() + } + }, []) + + const handleClose = (id: string) => { + setQueue(queue => queue.filter(el => el.id !== id)) + } + + return ( + <> + {queue.map((el, i) => ( + handleClose(el.id)} + > + {el.message} + + ))} + + ) +} + +export class Toast { + constructor() { + const container = document.createElement('div') + document.body.appendChild(container) + + ReactDOM.render(, container) + } + + success(message: string) { + emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'success', message }) + } + + info(message: string) { + emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'info', message }) + } + + warning(message: string) { + emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'warning', message }) + } + + error(message: string) { + emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'error', message }) + } + + clear() { + emitter.emit(CLEAR_EVENT) + } +} diff --git a/resources/assets/src/scripts/types.ts b/resources/assets/src/scripts/types.ts new file mode 100644 index 00000000..4f02a9c2 --- /dev/null +++ b/resources/assets/src/scripts/types.ts @@ -0,0 +1,24 @@ +export type Player = { + pid: number + name: string + tid_skin: number + tid_cape: number +} + +export type Texture = { + tid: number + name: string + type: TextureType + hash: string + size: number + uploader: number + public: boolean + upload_at: string + likes: number +} + +export type TextureType = 'steve' | 'alex' | 'cape' + +export type ClosetItem = Texture & { + pivot: { user_uid: number; texture_tid: number; item_name: string } +} diff --git a/resources/assets/src/scripts/utils.ts b/resources/assets/src/scripts/utils.ts index d5173a17..f801f1a1 100644 --- a/resources/assets/src/scripts/utils.ts +++ b/resources/assets/src/scripts/utils.ts @@ -1,11 +1,3 @@ -export function debounce(func: CallableFunction, delay: number) { - let timer: number - return () => { - clearTimeout(timer) - timer = setTimeout(func, delay) - } -} - export function queryString(key: string, defaultValue: string = ''): string { const result = new RegExp(`[?&]${key}=([^&]+)`, 'i').exec(location.search) @@ -16,8 +8,7 @@ export function queryString(key: string, defaultValue: string = ''): string { } export function queryStringify(params: { [key: string]: string }): string { - return Object - .keys(params) + return Object.keys(params) .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) .join('&') } diff --git a/resources/assets/src/scripts/validators.ts b/resources/assets/src/scripts/validators.ts index 06ea3805..17ca43bd 100644 --- a/resources/assets/src/scripts/validators.ts +++ b/resources/assets/src/scripts/validators.ts @@ -1,5 +1,5 @@ export function truthy(message: string) { - return (value?: unknown): string | void => { + return (value?: unknown): string | undefined => { if (!value) { return message } diff --git a/resources/assets/src/shims.d.ts b/resources/assets/src/shims.d.ts index fca61508..c9e3758d 100644 --- a/resources/assets/src/shims.d.ts +++ b/resources/assets/src/shims.d.ts @@ -1,25 +1,22 @@ import Vue from 'vue' -import * as JQuery from 'jquery' -import { ModalOptions, ModalResult } from './scripts/modal' +import JQuery from 'jquery' +import { ModalOptions, ModalResult } from './components/Modal' import { Toast } from './scripts/toast' -type I18n = 'en' | 'zh_CN' - declare global { // eslint-disable-next-line no-redeclare let blessing: { base_url: string debug: boolean env: string - fallback_locale: I18n - locale: I18n + fallback_locale: string + locale: string site_name: string timezone: string version: string route: string extra: any i18n: object - ui: object fetch: { get(url: string, params?: object): Promise diff --git a/resources/assets/src/styles/_breakpoints.scss b/resources/assets/src/styles/_breakpoints.scss new file mode 100644 index 00000000..0be643f5 --- /dev/null +++ b/resources/assets/src/styles/_breakpoints.scss @@ -0,0 +1,27 @@ +@use 'sass:map'; + +$breakpoints: ( + xs: 0, + sm: 576px, + md: 768px, + lg: 992px, + xl: 1200px, +); + +@mixin less-than($breakpoint) { + @media (max-width: map-get($breakpoints, $breakpoint)) { + @content; + } +} + +@mixin between($down, $up) { + @media (max-width: map-get($breakpoints, $down)) and (min-width: map-get($breakpoints, $up)) { + @content; + } +} + +@mixin greater-than($breakpoint) { + @media (min-width: map-get($breakpoints, $breakpoint)) { + @content; + } +} diff --git a/resources/assets/src/styles/_utils.scss b/resources/assets/src/styles/_utils.scss new file mode 100644 index 00000000..97ca7bb3 --- /dev/null +++ b/resources/assets/src/styles/_utils.scss @@ -0,0 +1,5 @@ +@mixin truncate-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/resources/assets/src/styles/common.styl b/resources/assets/src/styles/common.styl index 5e2dfba8..015b1a9d 100644 --- a/resources/assets/src/styles/common.styl +++ b/resources/assets/src/styles/common.styl @@ -30,13 +30,3 @@ .user-panel .info cursor default - -.alert-toast - position fixed - right calc((100% - 350px) / 2) - width 350px - z-index 1050 - transition-property top - transition-duration 0.3s - .alert - box-shadow 0 .25rem .75rem rgba(0,0,0,.1) diff --git a/resources/assets/src/styles/setup.styl b/resources/assets/src/styles/setup.styl deleted file mode 100644 index a4abd13f..00000000 --- a/resources/assets/src/styles/setup.styl +++ /dev/null @@ -1,224 +0,0 @@ -@import 'common.styl' - -html - background #f1f1f1 - margin 0 20px - font-weight 400 - -body - background #FFF none repeat scroll 0% 0% - color #444 - font-family Ubuntu, 'Microsoft Yahei', 'Microsoft Jhenghei', sans-serif - margin 140px auto 25px - padding 20px 20px 10px - max-width 700px - box-shadow 0px 1px 3px rgba(0, 0, 0, 0.13) - -h1, h2 - border-bottom 1px solid #DEDEDE - clear both - color #666 - font-size 24px - font-weight 400 - padding 0px 0px 7px - -p - padding-bottom 2px - font-size 14px - line-height 1.5 - -#logo - margin 6px 0 14px - border-bottom none - text-align center - - a - font-family Minecraft, sans-serif - transition color 0.2s ease-in-out - font-weight 400 - font-size 50px - color #666 - height 84px - line-height 1.3em - margin -130px auto 25px - padding 0 - outline 0 - text-decoration none - overflow hidden - display block - - a:hover - color #42a5f5 - -/* Mobile phone */ -@media (max-width: 48em) - #logo a - font-size 40px - -@media (max-width: 35.5em) - #logo a - font-size 30px - -#language-chooser - float right - margin-top 4px - height 24px - -.step - margin 20px 0 15px - text-align left - padding 0 - - input - font-family Ubuntu, 'Microsoft Yahei', 'Microsoft Jhenghei', sans-serif - -.alert - font-size 16px - padding 15px - margin-top 15px - margin-bottom 20px - border 1px solid transparent - border-radius 4px - - ul - margin 0px - -.alert-warning - color #8a6d3b - background-color #fcf8e3 - border-color #faebcc - -.form-table td, .form-table th - padding 10px 20px 10px 0 - vertical-align top - font-size 14px - -td > input - font-family Ubuntu, 'Microsoft Yahei', 'Microsoft Jhenghei', sans-serif - -.form-table - border-collapse collapse - margin-top 1em - width 100% - -.form-table td - margin-bottom 9px - -.form-table th - text-align left - width 140px - -.form-table p - margin 4px 0 0 - font-size 11px - -.form-table input - line-height 20px - font-size 15px - padding 3px 5px - border 1px solid #ddd - -webkit-box-shadow inset 0 1px 2px rgba(0, 0, 0, 0.07) - box-shadow inset 0 1px 2px rgba(0, 0, 0, 0.07) - -.form-table input[type=email], .form-table input[type=password], .form-table input[type=text], .form-table input[type=url] - width 206px - -.container .button, .container .button-primary, .container .button-secondary - display inline-block - text-decoration none - font-size 13px - line-height 26px - height 28px - margin 0 - padding 0 10px 1px - cursor pointer - border-width 1px - border-style solid - -webkit-appearance none - -webkit-border-radius 3px - border-radius 3px - white-space nowrap - -webkit-box-sizing border-box - -moz-box-sizing border-box - box-sizing border-box - -.container button::-moz-focus-inner, .container input[type=reset]::-moz-focus-inner, .container input[type=button]::-moz-focus-inner, .container input[type=submit]::-moz-focus-inner - border-width 0 - border-style none - padding 0 - -.container .button-group.button-large .button, .container .button.button-large - height 30px - line-height 28px - padding 0 12px 2px - -.container .button:active, .container .button:focus - outline 0 - -.container .button.hidden - display none - -.container input[type=reset], .container input[type=reset]:active, .container input[type=reset]:focus, .container input[type=reset]:hover - background 0 0 - border none - -webkit-box-shadow none - box-shadow none - padding 0 2px 1px - width auto - -.container .button, .container .button-secondary - color #555 - border-color #ccc - background #f7f7f7 - -webkit-box-shadow 0 1px 0 #ccc - box-shadow 0 1px 0 #ccc - vertical-align top - -.container p .button - vertical-align baseline - -.container .button-secondary:focus, .container .button-secondary:hover, .container .button.focus, .container .button.hover, .container .button:focus, .container .button:hover - background #fafafa - border-color #999 - color #23282d - -.container .button-link:focus, .container .button-secondary:focus, .container .button.focus, .container .button:focus - border-color #5b9dd9 - -webkit-box-shadow 0 0 3px rgba(0, 115, 170, 0.8) - box-shadow 0 0 3px rgba(0, 115, 170, 0.8) - -.container .button-secondary:active, .container .button.active, .container .button.active:hover, .container .button:active - background #eee - border-color #999 - -webkit-box-shadow inset 0 2px 5px -3px rgba(0, 0, 0, 0.5) - box-shadow inset 0 2px 5px -3px rgba(0, 0, 0, 0.5) - -webkit-transform translateY(1px) - -ms-transform translateY(1px) - transform translateY(1px) - -.container .button.active:focus - border-color #5b9dd9 - -webkit-box-shadow inset 0 2px 5px -3px rgba(0, 0, 0, 0.5), 0 0 3px rgba(0, 115, 170, 0.8) - box-shadow inset 0 2px 5px -3px rgba(0, 0, 0, 0.5), 0 0 3px rgba(0, 115, 170, 0.8) - -.container .button-disabled, .container .button-secondary.disabled, .container .button-secondary:disabled, .container .button-secondary[disabled], .container .button.disabled, .container .button:disabled, .container .button[disabled] - color #a0a5aa !important - border-color #ddd !important - background #f7f7f7 !important - -webkit-box-shadow none !important - box-shadow none !important - text-shadow 0 1px 0 #fff !important - cursor default - -webkit-transform none !important - -ms-transform none !important - transform none !important - -.faq - display inline-block - text-decoration none - background-color #0069d9 - color #fff - padding 5px 15px - margin 20px 0 6px 0 - border-radius 3px - diff --git a/resources/assets/src/styles/spectre.css b/resources/assets/src/styles/spectre.css new file mode 100644 index 00000000..f290c6d6 --- /dev/null +++ b/resources/assets/src/styles/spectre.css @@ -0,0 +1,15 @@ +body { + height: 97vh; +} + +.hero { + align-items: center; +} + +h1 { + font-family: Minecraft; +} + +a:hover { + text-decoration: none; +} diff --git a/resources/assets/src/views/admin/Plugins.vue b/resources/assets/src/views/admin/Plugins.vue deleted file mode 100644 index 3ef0fdc9..00000000 --- a/resources/assets/src/views/admin/Plugins.vue +++ /dev/null @@ -1,163 +0,0 @@ - - - - - diff --git a/resources/assets/src/views/admin/PluginsManagement/InfoBox.scss b/resources/assets/src/views/admin/PluginsManagement/InfoBox.scss new file mode 100644 index 00000000..ba450302 --- /dev/null +++ b/resources/assets/src/views/admin/PluginsManagement/InfoBox.scss @@ -0,0 +1,43 @@ +.box { + cursor: default; + transition-property: box-shadow; + transition-duration: 0.3s; + width: 32%; + @media (max-width: 1280px) { + width: 47%; + } + @media (max-width: 768px) { + width: 100%; + } + + &:hover { + box-shadow: 0 0.5rem 1rem rgba(#000, 0.15); + } +} + +.content { + max-width: 85%; +} + +.actions { + margin-top: -7px; + + a { + transition-property: color; + transition-duration: 0.3s; + color: #000; + &:hover { + color: #999; + } + &:not(:last-child) { + margin-right: 9px; + } + } +} + +.description { + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/resources/assets/src/views/admin/PluginsManagement/InfoBox.tsx b/resources/assets/src/views/admin/PluginsManagement/InfoBox.tsx new file mode 100644 index 00000000..831554a3 --- /dev/null +++ b/resources/assets/src/views/admin/PluginsManagement/InfoBox.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { trans } from '../../../scripts/i18n' +import { Plugin } from './types' +import styles from './InfoBox.scss' + +interface Props { + plugin: Plugin + onEnable(plugin: Plugin): void + onDisable(plugin: Plugin): void + onDelete(plugin: Plugin): void + baseUrl: string +} + +const InfoBox: React.FC = props => { + const { plugin } = props + + const handleChange = (event: React.ChangeEvent) => { + event.preventDefault() + + if (event.target.checked) { + props.onEnable(plugin) + } else { + props.onDisable(plugin) + } + } + + const handleDelete = () => props.onDelete(plugin) + + return ( +
    + + + +
    +
    +
    + + {plugin.title} + v{plugin.version} +
    +
    + {plugin.readme && ( + + + + )} + {plugin.enabled && plugin.config && ( + + + + )} + + + +
    +
    +
    {plugin.description}
    +
    +
    + ) +} + +export default React.memo(InfoBox) diff --git a/resources/assets/src/views/admin/PluginsManagement/index.tsx b/resources/assets/src/views/admin/PluginsManagement/index.tsx new file mode 100644 index 00000000..2c723921 --- /dev/null +++ b/resources/assets/src/views/admin/PluginsManagement/index.tsx @@ -0,0 +1,114 @@ +import React, { useState, useEffect } from 'react' +import { hot } from 'react-hot-loader/root' +import { trans } from '../../../scripts/i18n' +import * as fetch from '../../../scripts/net' +import { toast, showModal } from '../../../scripts/notify' +import Loading from '../../../components/Loading' +import InfoBox from './InfoBox' +import { Plugin } from './types' + +const PluginsManagement: React.FC = () => { + const [loading, setLoading] = useState(false) + const [plugins, setPlugins] = useState([]) + useEffect(() => { + const getPlugins = async () => { + setLoading(true) + setPlugins(await fetch.get('/admin/plugins/data')) + setLoading(false) + } + getPlugins() + }, []) + + const handleEnable = async (plugin: Plugin, i: number) => { + const { + code, + message, + data: { reason } = { reason: [] }, + }: fetch.ResponseBody<{ + reason: string[] + }> = await fetch.post('/admin/plugins/manage', { + action: 'enable', + name: plugin.name, + }) + if (code === 0) { + toast.success(message) + setPlugins(plugins => { + plugins.splice(i, 1, { ...plugin, enabled: true }) + return plugins.slice() + }) + } else { + showModal({ + mode: 'alert', + children: ( +
    +

    {message}

    +
      + {reason.map((t, i) => ( +
    • {t}
    • + ))} +
    +
    + ), + }) + } + } + + const handleDisable = async (plugin: Plugin, i: number) => { + const { code, message } = await fetch.post('/admin/plugins/manage', { + action: 'disable', + name: plugin.name, + }) + if (code === 0) { + toast.success(message) + setPlugins(plugins => { + plugins.splice(i, 1, { ...plugin, enabled: false }) + return plugins.slice() + }) + } else { + toast.error(message) + } + } + + const handleDelete = async (plugin: Plugin) => { + try { + await showModal({ + title: plugin.title, + text: trans('admin.confirmDeletion'), + okButtonType: 'danger', + }) + } catch { + return + } + + const { code, message } = await fetch.post('/admin/plugins/manage', { + action: 'delete', + name: plugin.name, + }) + if (code === 0) { + const { name } = plugin + setPlugins(plugins => plugins.filter(plugin => plugin.name !== name)) + toast.success(message) + } else { + toast.error(message) + } + } + + return loading ? ( + + ) : ( +
    + {plugins.map((plugin, i) => ( + handleEnable(plugin, i)} + onDisable={plugin => handleDisable(plugin, i)} + onDelete={handleDelete} + baseUrl={blessing.base_url} + /> + ))} +
    + ) +} + +export default hot(PluginsManagement) diff --git a/resources/assets/src/views/admin/PluginsManagement/types.ts b/resources/assets/src/views/admin/PluginsManagement/types.ts new file mode 100644 index 00000000..3ebf9d04 --- /dev/null +++ b/resources/assets/src/views/admin/PluginsManagement/types.ts @@ -0,0 +1,10 @@ +export type Plugin = { + name: string + title: string + description: string + version: string + enabled: boolean + config: boolean + readme: boolean + icon: { fa: string; faType: 'fas' | 'fab'; bg: string } +} diff --git a/resources/assets/src/views/user/Bind.vue b/resources/assets/src/views/user/Bind.vue deleted file mode 100644 index 5fceebbf..00000000 --- a/resources/assets/src/views/user/Bind.vue +++ /dev/null @@ -1,83 +0,0 @@ - - - - - diff --git a/resources/assets/src/views/user/BindPlayers.tsx b/resources/assets/src/views/user/BindPlayers.tsx new file mode 100644 index 00000000..111427a7 --- /dev/null +++ b/resources/assets/src/views/user/BindPlayers.tsx @@ -0,0 +1,98 @@ +import React, { useState, useEffect } from 'react' +import { hot } from 'react-hot-loader/root' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import { showModal } from '@/scripts/notify' +import { Player } from '@/scripts/types' +import Loading from '@/components/Loading' + +const BindPlayers: React.FC = () => { + const [players, setPlayers] = useState([]) + const [selected, setSelected] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [isPending, setIsPending] = useState(false) + + useEffect(() => { + const getPlayers = async () => { + setIsLoading(true) + const response = await fetch.get>( + '/user/player/list', + ) + const players = response.data.map(player => player.name) + setPlayers(players) + setSelected(players[0]) + setIsLoading(false) + } + getPlayers() + }, []) + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setIsPending(true) + + const { code, message } = await fetch.post( + '/user/player/bind', + { player: selected }, + ) + if (code === 0) { + await showModal({ mode: 'alert', text: message }) + window.location.href = `${blessing.base_url}/user` + } else { + showModal({ mode: 'alert', text: message }) + } + + setIsPending(false) + } + + return isLoading ? ( + + ) : ( +
    + {players.length > 0 ? ( + <> +

    {t('user.bindExistedPlayer')}

    +
    + {players.map(player => ( + + ))} +
    + + ) : ( + <> +

    {t('user.bindNewPlayer')}

    + setSelected(e.target.value)} + /> + + )} + +
    + ) +} + +export default hot(BindPlayers) diff --git a/resources/assets/src/views/user/Closet.vue b/resources/assets/src/views/user/Closet.vue deleted file mode 100644 index 0952cc73..00000000 --- a/resources/assets/src/views/user/Closet.vue +++ /dev/null @@ -1,294 +0,0 @@ - - - diff --git a/resources/assets/src/views/user/Closet/ClosetItem.module.scss b/resources/assets/src/views/user/Closet/ClosetItem.module.scss new file mode 100644 index 00000000..82048e93 --- /dev/null +++ b/resources/assets/src/views/user/Closet/ClosetItem.module.scss @@ -0,0 +1,23 @@ +@use '../../../styles/utils'; + +.item { + width: 235px; + transition-property: box-shadow; + transition-duration: 0.3s; + + &:hover { + cursor: pointer; + } +} + +.icon:hover { + color: #000; +} + +.bg { + background-color: #eff1f0; +} + +.truncate { + @include utils.truncate-text; +} diff --git a/resources/assets/src/views/user/Closet/ClosetItem.tsx b/resources/assets/src/views/user/Closet/ClosetItem.tsx new file mode 100644 index 00000000..74d776fe --- /dev/null +++ b/resources/assets/src/views/user/Closet/ClosetItem.tsx @@ -0,0 +1,98 @@ +import React from 'react' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import { showModal, toast } from '@/scripts/notify' +import { ClosetItem } from '@/scripts/types' +import styles from './ClosetItem.module.scss' + +interface Props { + item: ClosetItem + selected: boolean + onClick(item: ClosetItem): void + onRename(): void + onRemove(): void +} + +const ClosetItem: React.FC = props => { + const { item } = props + + const handleItemClick = () => { + props.onClick(item) + } + + const setAsAvatar = async () => { + try { + await showModal({ + title: t('user.setAvatar'), + text: t('user.setAvatarNotice'), + }) + } catch { + return + } + + const { code, message } = await fetch.post( + '/user/profile/avatar', + { tid: item.tid }, + ) + if (code === 0) { + toast.success(message) + document + .querySelectorAll('[alt="User Image"]') + .forEach(el => (el.src += `?${new Date().getTime()}`)) + } else { + toast.error(message) + } + } + + return ( + + ) +} + +export default ClosetItem diff --git a/resources/assets/src/views/user/Closet/ModalApply.tsx b/resources/assets/src/views/user/Closet/ModalApply.tsx new file mode 100644 index 00000000..a199015c --- /dev/null +++ b/resources/assets/src/views/user/Closet/ModalApply.tsx @@ -0,0 +1,108 @@ +import React, { useState, useEffect } from 'react' +import $ from 'jquery' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import { toast } from '@/scripts/notify' +import { Player } from '@/scripts/types' +import Loading from '@/components/Loading' +import Modal from '@/components/Modal' + +const baseUrl = blessing.base_url + +interface Props { + show: boolean + canAdd: boolean + skin?: number + cape?: number + onClose(): void +} + +const ModalApply: React.FC = props => { + const [players, setPlayers] = useState([]) + const [search, setSearch] = useState('') + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (!props.show) { + return + } + + const getPlayers = async () => { + setIsLoading(true) + const { data } = await fetch.get>( + '/user/player/list', + ) + setPlayers(data) + setIsLoading(false) + } + getPlayers() + }, [props.show]) + + const handleSearch = (event: React.ChangeEvent) => { + setSearch(event.target.value) + } + + const handleSelect = async (player: Player) => { + const { code, message } = await fetch.post( + `/user/player/set/${player.pid}`, + { + skin: props.skin, + cape: props.cape, + }, + ) + if (code === 0) { + toast.success(message) + $('#modal-apply').modal('hide') + } else { + toast.error(message) + } + } + + return ( + } + onClose={props.onClose} + > + {isLoading ? ( + + ) : players.length === 0 ? ( +

    {t('user.closet.use-as.empty')}

    + ) : ( + <> +
    + +
    + {players + .filter(player => player.name.includes(search)) + .map(player => ( + + ))} + + )} +
    + ) +} + +export default ModalApply diff --git a/resources/assets/src/views/user/Closet/Previewer.tsx b/resources/assets/src/views/user/Closet/Previewer.tsx new file mode 100644 index 00000000..ab956bab --- /dev/null +++ b/resources/assets/src/views/user/Closet/Previewer.tsx @@ -0,0 +1,38 @@ +import React, { useEffect } from 'react' +import ReactDOM from 'react-dom' +import ViewerSkeleton from '@/components/ViewerSkeleton' + +const Viewer = React.lazy(() => import('@/components/Viewer')) + +interface Props { + skin?: string + cape?: string + isAlex: boolean +} + +const container = document.createElement('div') + +const Previewer: React.FC = props => { + useEffect(() => { + const mount = document.querySelector('#previewer')! + mount.appendChild(container) + + return () => { + mount.removeChild(container) + } + }, []) + + const skin = props.skin ? `${blessing.base_url}/textures/${props.skin}` : '' + const cape = props.cape ? `${blessing.base_url}/textures/${props.cape}` : '' + + return ReactDOM.createPortal( + }> + + {props.children} + + , + container, + ) +} + +export default Previewer diff --git a/resources/assets/src/views/user/Closet/index.tsx b/resources/assets/src/views/user/Closet/index.tsx new file mode 100644 index 00000000..a5eef8f2 --- /dev/null +++ b/resources/assets/src/views/user/Closet/index.tsx @@ -0,0 +1,275 @@ +import React, { useState, useEffect } from 'react' +import { hot } from 'react-hot-loader/root' +import debounce from 'lodash.debounce' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import { showModal, toast } from '@/scripts/notify' +import { ClosetItem as Item, Texture } from '@/scripts/types' +import Loading from '@/components/Loading' +import Pagination from '@/components/Pagination' +import ClosetItem from './ClosetItem' +import Previewer from './Previewer' +import ModalApply from './ModalApply' + +type Category = 'skin' | 'cape' + +const updater = debounce( + ( + value: React.SetStateAction, + setter: React.Dispatch>, + ) => setter(value), + 350, +) + +const Closet: React.FC = () => { + const [isLoading, setIsLoading] = useState(false) + const [category, setCategory] = useState('skin') + const [search, setSearch] = useState('') + const [query, setQuery] = useState('') + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const [items, setItems] = useState([]) + const [skin, setSkin] = useState(null) + const [cape, setCape] = useState(null) + const [showModalApply, setShowModalApply] = useState(false) + + useEffect(() => { + const getItems = async () => { + setIsLoading(true) + const { + data: { items, category: c, total_pages: totalPages }, + } = await fetch.get< + fetch.ResponseBody<{ + items: Item[] + category: Category + total_pages: number + }> + >('/user/closet/list', { + category, + q: query, + page, + }) + + setItems(items) + setCategory(c) + setTotalPages(totalPages) + setIsLoading(false) + } + getItems() + }, [category, query, page]) + + const switchCategory = () => { + setCategory(category => (category === 'skin' ? 'cape' : 'skin')) + } + + const handleSearch = (event: React.ChangeEvent) => { + const value = event.target.value + setSearch(value) + updater(value, setQuery) + } + + const handlePageChange = (page: number) => setPage(page) + + const isSelected = (item: Item): boolean => { + if (category === 'skin') { + return item.tid === skin?.tid + } else { + return item.tid === cape?.tid + } + } + + const handleSelect = (item: Item) => { + if (item.type === 'cape') { + setCape(item) + } else { + setSkin(item) + } + } + + const resetSelected = () => { + setSkin(null) + setCape(null) + } + + const renameItem = async (item: Item, index: number) => { + let name: string + try { + const { value } = await showModal({ + mode: 'prompt', + text: t('user.renameClosetItem'), + input: item.pivot.item_name, + validator: (value: string) => { + if (!value) { + return t('skinlib.emptyNewTextureName') + } + }, + }) + name = value + } catch { + return + } + + const { code, message } = await fetch.post( + `/user/closet/rename/${item.tid}`, + { name }, + ) + if (code === 0) { + toast.success(message) + setItems(items => { + items[index] = Object.assign({}, item, { + pivot: Object.assign({}, item.pivot, { item_name: name }), + }) + return items.slice() + }) + } else { + toast.error(message) + } + } + + const removeItem = async (item: Item) => { + try { + await showModal({ + text: t('user.removeFromClosetNotice'), + okButtonType: 'danger', + }) + } catch { + return + } + + const { code, message } = await fetch.post( + `/user/closet/remove/${item.tid}`, + ) + if (code === 0) { + toast.success(message) + const { tid } = item + setItems(items => items.filter(item => item.tid !== tid)) + } else { + toast.error(message) + } + } + + const applyToPlayer = () => { + if (!skin && !cape) { + toast.info(t('user.emptySelectedTexture')) + return + } + setShowModalApply(true) + } + + return ( + <> +
    + +
    + {isLoading ? ( + + ) : items.length === 0 ? ( +
    + {search ? ( + t('general.noResult') + ) : ( + + )} +
    + ) : ( +
    + {items.map((item, i) => ( + renameItem(item, i)} + onRemove={() => removeItem(item)} + /> + ))} +
    + )} +
    +
    +
    + +
    +
    +
    + +
    + + +
    +
    + setShowModalApply(false)} + /> + + ) +} + +export default hot(Closet) diff --git a/resources/assets/src/views/user/Dashboard.vue b/resources/assets/src/views/user/Dashboard.vue deleted file mode 100644 index e83f54ce..00000000 --- a/resources/assets/src/views/user/Dashboard.vue +++ /dev/null @@ -1,212 +0,0 @@ - - - - - diff --git a/resources/assets/src/views/user/Dashboard/InfoBox.tsx b/resources/assets/src/views/user/Dashboard/InfoBox.tsx new file mode 100644 index 00000000..6fcb7143 --- /dev/null +++ b/resources/assets/src/views/user/Dashboard/InfoBox.tsx @@ -0,0 +1,33 @@ +import React from 'react' + +interface Props { + name: string + icon: string + color: string + used: number + total: number + unit: string +} + +const InfoBox: React.FC = props => { + const percentage = (props.used / props.total) * 100 + + return ( +
    + + + +
    + {props.name} + + {props.used} / {props.total} {props.unit} + +
    +
    +
    +
    +
    + ) +} + +export default React.memo(InfoBox) diff --git a/resources/assets/src/views/user/Dashboard/SignButton.tsx b/resources/assets/src/views/user/Dashboard/SignButton.tsx new file mode 100644 index 00000000..f8c6df4f --- /dev/null +++ b/resources/assets/src/views/user/Dashboard/SignButton.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { trans } from '../../../scripts/i18n' +import * as scoreUtils from './scoreUtils' + +interface Props { + isLoading: boolean + lastSign: Date + canSignAfterZero: boolean + signGap: number + onClick: React.MouseEventHandler +} + +const SignButton: React.FC = props => { + const { lastSign, signGap, canSignAfterZero } = props + const remainingTime = scoreUtils.remainingTime( + lastSign, + signGap, + canSignAfterZero, + ) + const remainingTimeText = scoreUtils.remainingTimeText(remainingTime) + const canSign = remainingTime <= 0 + + return ( +