Merge branch 'react' into dev
This commit is contained in:
commit
76bf342681
3
.github/workflows/CI.yml
vendored
3
.github/workflows/CI.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
39
package.json
39
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": {
|
||||
"^@/(.*)$": "<rootDir>/resources/assets/src/$1",
|
||||
"\\.css$": "<rootDir>/resources/assets/tests/__mocks__/style.ts",
|
||||
"\\.(s?css|styl)$": "<rootDir>/resources/assets/tests/__mocks__/style.ts",
|
||||
"\\.(png|jpg)$": "<rootDir>/resources/assets/tests/__mocks__/file.ts"
|
||||
},
|
||||
"setupFilesAfterEnv": [
|
||||
|
|
@ -204,6 +222,15 @@
|
|||
"<rootDir>/resources/assets/tests/setup",
|
||||
"<rootDir>/resources/assets/tests/utils"
|
||||
],
|
||||
"testRegex": "resources/assets/tests/.*\\.(spec|test)\\.(t|j)s$"
|
||||
"testMatch": [
|
||||
"<rootDir>/resources/assets/tests/**/*.test.ts",
|
||||
"<rootDir>/resources/assets/tests/**/*.test.tsx"
|
||||
],
|
||||
"globals": {
|
||||
"ts-jest": {
|
||||
"tsConfig": "<rootDir>/resources/assets/tests/tsconfig.json",
|
||||
"isolatedModules": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
<template>
|
||||
<modal
|
||||
id="modal-add-player"
|
||||
:title="$t('user.player.add-player')"
|
||||
:ok-button-text="$t('general.submit')"
|
||||
flex-footer
|
||||
center
|
||||
@confirm="addPlayer"
|
||||
>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td v-t="'general.player.player-name'" class="key" />
|
||||
<td class="value">
|
||||
<input v-model="name" class="form-control" type="text">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="callout callout-info">
|
||||
<ul class="m-0 p-0 pl-3">
|
||||
<li>{{ rule }}</li>
|
||||
<li>{{ length }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from './Modal.vue'
|
||||
import { toast } from '../scripts/notify'
|
||||
|
||||
export default {
|
||||
name: 'AddPlayerDialog',
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
rule: blessing.extra.rule,
|
||||
length: blessing.extra.length,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async addPlayer() {
|
||||
const { code, message } = await this.$http.post(
|
||||
'/user/player/add',
|
||||
{ name: this.name },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
this.$emit('add')
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
14
resources/assets/src/components/ButtonEdit.tsx
Normal file
14
resources/assets/src/components/ButtonEdit.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
onClick: React.MouseEventHandler<HTMLAnchorElement>
|
||||
}
|
||||
|
||||
const ButtonEdit: React.FC<Props> = props => (
|
||||
<a href="#" title={props.title} className="ml-2" onClick={props.onClick}>
|
||||
<i className="fas fa-edit"></i>
|
||||
</a>
|
||||
)
|
||||
|
||||
export default ButtonEdit
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
<template>
|
||||
<div class="card mr-3 mb-3 closet-item" :class="{ shadow: selected }">
|
||||
<div class="card-body texture-img" @click="$emit('select')">
|
||||
<img class="card-img-top" :src="previewLink">
|
||||
</div>
|
||||
<div class="card-footer pb-2 pt-2 pl-1 pr-1">
|
||||
<div class="container d-flex justify-content-between">
|
||||
<span data-test="name" :title="name">
|
||||
{{ textureName|truncate }} <small>({{ type }})</small>
|
||||
</span>
|
||||
|
||||
<a class="float-right dropdown">
|
||||
<span
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i class="fas fa-cog text-gray" />
|
||||
</span>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item" href="#" @click="rename">
|
||||
{{ $t('user.renameItem') }}
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" @click="removeClosetItem">
|
||||
{{ $t('user.removeItem') }}
|
||||
</a>
|
||||
<a :href="linkToSkinlib" class="dropdown-item">
|
||||
{{ $t('user.viewInSkinlib') }}
|
||||
</a>
|
||||
<a
|
||||
v-if="type !== 'cape'"
|
||||
class="dropdown-item"
|
||||
href="#"
|
||||
@click="setAsAvatar"
|
||||
>{{ $t('user.setAsAvatar') }}</a>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import setAsAvatar from './mixins/setAsAvatar'
|
||||
import removeClosetItem from './mixins/removeClosetItem'
|
||||
import truncateText from './mixins/truncateText'
|
||||
import { showModal, toast } from '../scripts/notify'
|
||||
import { truthy } from '../scripts/validators'
|
||||
|
||||
export default {
|
||||
name: 'ClosetItem',
|
||||
mixins: [
|
||||
removeClosetItem,
|
||||
setAsAvatar,
|
||||
truncateText,
|
||||
],
|
||||
props: {
|
||||
tid: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
validator: value => ['steve', 'alex', 'cape'].includes(value),
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
selected: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
textureName: this.name,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
previewLink() {
|
||||
return `${blessing.base_url}/preview/${this.tid}?height=150`
|
||||
},
|
||||
linkToSkinlib() {
|
||||
return `${blessing.base_url}/skinlib/show/${this.tid}`
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async rename() {
|
||||
let newTextureName
|
||||
try {
|
||||
({ value: newTextureName } = await showModal(
|
||||
{
|
||||
mode: 'prompt',
|
||||
text: this.$t('user.renameClosetItem'),
|
||||
input: this.textureName,
|
||||
validator: truthy(this.$t('skinlib.emptyNewTextureName')),
|
||||
},
|
||||
))
|
||||
} catch (_) {
|
||||
return
|
||||
}
|
||||
|
||||
const { code, message } = await this.$http.post(
|
||||
`/user/closet/rename/${this.tid}`,
|
||||
{ name: newTextureName },
|
||||
)
|
||||
if (code === 0) {
|
||||
this.textureName = newTextureName
|
||||
toast.success(message)
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus">
|
||||
.closet-item
|
||||
width 235px
|
||||
transition-property box-shadow
|
||||
transition-duration 0.3s
|
||||
&:hover
|
||||
box-shadow 0 .5rem 1rem rgba(0,0,0,.15)
|
||||
cursor pointer
|
||||
|
||||
.fa-cog:hover
|
||||
color #000
|
||||
|
||||
.texture-img
|
||||
background #eff1f0
|
||||
</style>
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
<template>
|
||||
<div v-if="!verified" class="callout callout-info">
|
||||
<h4><i class="fas fa-envelope" /> {{ $t('user.verification.title') }}</h4>
|
||||
<p>
|
||||
{{ $t('user.verification.message') }}
|
||||
<span v-if="pending">
|
||||
<i class="fas fa-spin fa-spinner" />
|
||||
{{ $t('user.verification.sending') }}
|
||||
</span>
|
||||
<a v-else href="#" @click="resend">
|
||||
{{ $t('user.verification.resend') }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { toast } from '../scripts/notify'
|
||||
|
||||
export default {
|
||||
name: 'EmailVerification',
|
||||
data() {
|
||||
return {
|
||||
verified: !blessing.extra.unverified,
|
||||
pending: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async resend() {
|
||||
this.pending = true
|
||||
const { code, message } = await this.$http.post('/user/email-verification')
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
this.pending = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
9
resources/assets/src/components/Loading.tsx
Normal file
9
resources/assets/src/components/Loading.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
|
||||
const Loading = () => (
|
||||
<div className="container text-center" title="Loading...">
|
||||
<i className="fas fa-sync fa-spin"></i>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default Loading
|
||||
175
resources/assets/src/components/Modal.tsx
Normal file
175
resources/assets/src/components/Modal.tsx
Normal file
|
|
@ -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<ModalOptions & Props> = props => {
|
||||
const [value, setValue] = useState(props.input!)
|
||||
const [valid, setValid] = useState(true)
|
||||
const [validatorMessage, setValidatorMessage] = useState('')
|
||||
const ref = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div id={props.id} className="modal fade" role="dialog" ref={ref}>
|
||||
<div
|
||||
className={`modal-dialog ${
|
||||
props.center ? 'modal-dialog-centered' : ''
|
||||
}`}
|
||||
role="document"
|
||||
>
|
||||
<div className={`modal-content bg-${props.type}`}>
|
||||
<ModalHeader
|
||||
show={props.showHeader}
|
||||
title={props.title}
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
<ModalBody
|
||||
text={props.text}
|
||||
dangerousHTML={props.dangerousHTML}
|
||||
showInput={props.mode === 'prompt'}
|
||||
value={value}
|
||||
choices={props.choices}
|
||||
onChange={handleInputChange}
|
||||
inputType={props.inputType}
|
||||
placeholder={props.placeholder}
|
||||
invalid={!valid}
|
||||
validatorMessage={validatorMessage}
|
||||
>
|
||||
{props.children}
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
showCancelButton={props.mode !== 'alert'}
|
||||
flexFooter={props.flexFooter}
|
||||
okButtonType={props.okButtonType}
|
||||
okButtonText={props.okButtonText}
|
||||
cancelButtonType={props.cancelButtonType}
|
||||
cancelButtonText={props.cancelButtonText}
|
||||
onConfirm={confirm}
|
||||
onDismiss={dismiss}
|
||||
>
|
||||
{props.footer}
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
29
resources/assets/src/components/ModalBody.tsx
Normal file
29
resources/assets/src/components/ModalBody.tsx
Normal file
|
|
@ -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<HTMLInputElement>
|
||||
placeholder?: string
|
||||
invalid?: boolean
|
||||
validatorMessage?: string
|
||||
}
|
||||
|
||||
const ModalBody: React.FC<Props> = props => {
|
||||
return (
|
||||
<div className="modal-body">
|
||||
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
|
||||
{props.children}
|
||||
</ModalContent>
|
||||
{props.showInput && <ModalInput {...props} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalBody
|
||||
26
resources/assets/src/components/ModalContent.tsx
Normal file
26
resources/assets/src/components/ModalContent.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
text?: string
|
||||
dangerousHTML?: string
|
||||
}
|
||||
|
||||
const ModalContent: React.FC<Props> = props => {
|
||||
if (props.children) {
|
||||
return <>{props.children}</>
|
||||
} else if (props.text) {
|
||||
return (
|
||||
<>
|
||||
{props.text.split(/\r?\n/).map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
} else if (props.dangerousHTML) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: props.dangerousHTML }} />
|
||||
}
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
||||
export default ModalContent
|
||||
46
resources/assets/src/components/ModalFooter.tsx
Normal file
46
resources/assets/src/components/ModalFooter.tsx
Normal file
|
|
@ -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> = props => {
|
||||
const classes = ['modal-footer']
|
||||
if (props.flexFooter) {
|
||||
classes.push('d-flex', 'justify-content-between')
|
||||
}
|
||||
const footerClass = classes.join(' ')
|
||||
|
||||
return props.children ? (
|
||||
<div className={footerClass}>{props.children}</div>
|
||||
) : (
|
||||
<div className={footerClass}>
|
||||
{props.showCancelButton && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-${props.cancelButtonType}`}
|
||||
data-dismiss="modal"
|
||||
onClick={props.onDismiss}
|
||||
>
|
||||
{props.cancelButtonText}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-${props.okButtonType}`}
|
||||
onClick={props.onConfirm}
|
||||
>
|
||||
{props.okButtonText}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalFooter
|
||||
25
resources/assets/src/components/ModalHeader.tsx
Normal file
25
resources/assets/src/components/ModalHeader.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
show?: boolean
|
||||
title?: string
|
||||
onDismiss?(): void
|
||||
}
|
||||
|
||||
const ModalHeader: React.FC<Props> = props =>
|
||||
props.show ? (
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{props.title}</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="close"
|
||||
data-dismiss="modal"
|
||||
aria-label="Close"
|
||||
onClick={props.onDismiss}
|
||||
>
|
||||
<span aria-hidden>×</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
export default ModalHeader
|
||||
52
resources/assets/src/components/ModalInput.tsx
Normal file
52
resources/assets/src/components/ModalInput.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
inputType?: string
|
||||
value?: string
|
||||
choices?: { text: string; value: string }[]
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>
|
||||
placeholder?: string
|
||||
invalid?: boolean
|
||||
validatorMessage?: string
|
||||
}
|
||||
|
||||
const ModalInput: React.FC<Props> = props => (
|
||||
<>
|
||||
{props.inputType === 'radios' && props.choices ? (
|
||||
<>
|
||||
{props.choices.map(choice => (
|
||||
<div key={choice.value}>
|
||||
<input
|
||||
type="radio"
|
||||
name="modal-radios"
|
||||
id={`modal-radio-${choice.value}`}
|
||||
value={choice.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<label htmlFor={`modal-radio-${choice.value}`} className="ml-1">
|
||||
{choice.text}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="form-group">
|
||||
<input
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
type={props.inputType}
|
||||
className="form-control"
|
||||
placeholder={props.placeholder}
|
||||
></input>
|
||||
</div>
|
||||
)}
|
||||
{props.invalid && (
|
||||
<div className="alert alert-danger">
|
||||
<i className="icon far fa-times-circle"></i>
|
||||
<span className="ml-1">{props.validatorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
export default ModalInput
|
||||
99
resources/assets/src/components/Pagination.tsx
Normal file
99
resources/assets/src/components/Pagination.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import React from 'react'
|
||||
import PaginationItem from './PaginationItem'
|
||||
|
||||
interface Props {
|
||||
page: number
|
||||
totalPages: number
|
||||
onChange(page: number): void | Promise<void>
|
||||
}
|
||||
|
||||
export const labels = {
|
||||
first: '«',
|
||||
prev: '‹',
|
||||
next: '›',
|
||||
last: '»',
|
||||
}
|
||||
|
||||
const Pagination: React.FC<Props> = props => {
|
||||
const { page, totalPages, onChange } = props
|
||||
|
||||
if (totalPages < 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="pagination">
|
||||
<PaginationItem disabled={page === 1} onClick={() => onChange(1)}>
|
||||
{labels.first}
|
||||
</PaginationItem>
|
||||
<PaginationItem disabled={page === 1} onClick={() => onChange(page - 1)}>
|
||||
{labels.prev}
|
||||
</PaginationItem>
|
||||
{totalPages < 8 ? (
|
||||
Array.from({ length: totalPages }).map((_, i) => (
|
||||
<PaginationItem
|
||||
key={i}
|
||||
active={page === i + 1}
|
||||
onClick={() => onChange(i + 1)}
|
||||
>
|
||||
{i + 1}
|
||||
</PaginationItem>
|
||||
))
|
||||
) : page < 3 || totalPages - page < 2 ? (
|
||||
<>
|
||||
{[1, 2].map(p => (
|
||||
<PaginationItem
|
||||
key={p}
|
||||
active={page === p}
|
||||
onClick={() => onChange(p)}
|
||||
>
|
||||
{p}
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem disabled>...</PaginationItem>
|
||||
{[totalPages - 1, totalPages].map(p => (
|
||||
<PaginationItem
|
||||
key={p}
|
||||
active={page === p}
|
||||
onClick={() => onChange(p)}
|
||||
>
|
||||
{p}
|
||||
</PaginationItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PaginationItem onClick={() => onChange(1)}>1</PaginationItem>
|
||||
<PaginationItem disabled>...</PaginationItem>
|
||||
{[page - 1, page, page + 1].map(p => (
|
||||
<PaginationItem
|
||||
key={p}
|
||||
active={page === p}
|
||||
onClick={() => onChange(p)}
|
||||
>
|
||||
{p}
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem disabled>...</PaginationItem>
|
||||
<PaginationItem onClick={() => onChange(totalPages)}>
|
||||
{totalPages}
|
||||
</PaginationItem>
|
||||
</>
|
||||
)}
|
||||
<PaginationItem
|
||||
disabled={page === totalPages}
|
||||
onClick={() => onChange(page + 1)}
|
||||
>
|
||||
{labels.next}
|
||||
</PaginationItem>
|
||||
<PaginationItem
|
||||
disabled={page === totalPages}
|
||||
onClick={() => onChange(totalPages)}
|
||||
>
|
||||
{labels.last}
|
||||
</PaginationItem>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default Pagination
|
||||
33
resources/assets/src/components/PaginationItem.tsx
Normal file
33
resources/assets/src/components/PaginationItem.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
onClick?(): void
|
||||
}
|
||||
|
||||
const PaginationItem: React.FC<Props> = 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 (
|
||||
<li className={classes.join(' ')} onClick={handleClick}>
|
||||
<a href="#" className="page-link" aria-disabled={props.disabled}>
|
||||
{props.children}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaginationItem
|
||||
12
resources/assets/src/components/Toast.scss
Normal file
12
resources/assets/src/components/Toast.scss
Normal file
|
|
@ -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);
|
||||
}
|
||||
69
resources/assets/src/components/Toast.tsx
Normal file
69
resources/assets/src/components/Toast.tsx
Normal file
|
|
@ -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<void>
|
||||
}
|
||||
|
||||
const icons = new Map<ToastType, string>([
|
||||
['success', 'check'],
|
||||
['info', 'info'],
|
||||
['warning', 'exclamation-triangle'],
|
||||
['error', 'times-circle'],
|
||||
])
|
||||
|
||||
const Toast: React.FC<Props> = 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 (
|
||||
<div className={styles.toast} style={{ top: `${props.distance}px` }}>
|
||||
<div className={classes.join(' ')} role={role}>
|
||||
<span className="mr-auto">
|
||||
<i className={`icon fas fa-${icons.get(props.type)}`}></i>
|
||||
<span>{props.children}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-2 ml-1 close"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Toast.displayName = 'Toast'
|
||||
|
||||
export default Toast
|
||||
20
resources/assets/src/components/Viewer.scss
Normal file
20
resources/assets/src/components/Viewer.scss
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
192
resources/assets/src/components/Viewer.tsx
Normal file
192
resources/assets/src/components/Viewer.tsx
Normal file
|
|
@ -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> = props => {
|
||||
const viewRef: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!)
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex justify-content-between">
|
||||
<h3 className="card-title">
|
||||
<span>{trans('general.texturePreview')}</span>
|
||||
{props.showIndicator && (
|
||||
<span className="badge bg-olive ml-1">{indicator}</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className={styles.actions}>
|
||||
<i
|
||||
className="fas fa-forward"
|
||||
data-toggle="tooltip"
|
||||
data-placement="bottom"
|
||||
title={`${trans('general.walk')} / ${trans('general.run')}`}
|
||||
onClick={toggleRun}
|
||||
></i>
|
||||
<i
|
||||
className="fas fa-redo-alt"
|
||||
data-toggle="tooltip"
|
||||
data-placement="bottom"
|
||||
title={trans('general.rotation')}
|
||||
onClick={toggleRotate}
|
||||
></i>
|
||||
<i
|
||||
className={`fas fa-${paused ? 'play' : 'pause'}`}
|
||||
data-toggle="tooltip"
|
||||
data-placement="bottom"
|
||||
title={trans('general.pause')}
|
||||
onClick={togglePause}
|
||||
></i>
|
||||
<i
|
||||
className="fas fa-stop"
|
||||
data-toggle="tooltip"
|
||||
data-placement="bottom"
|
||||
title={trans('general.reset')}
|
||||
onClick={handleReset}
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div ref={containerRef} className={styles.viewer}></div>
|
||||
</div>
|
||||
{props.children && <div className="card-footer">{props.children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Viewer.defaultProps = {
|
||||
initPositionZ: 70,
|
||||
}
|
||||
|
||||
export default Viewer
|
||||
17
resources/assets/src/components/ViewerSkeleton.tsx
Normal file
17
resources/assets/src/components/ViewerSkeleton.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react'
|
||||
import { trans } from '@/scripts/i18n'
|
||||
|
||||
const ViewerSkeleton: React.FC = () => (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex justify-content-between">
|
||||
<h3 className="card-title">
|
||||
<span>{trans('general.texturePreview')}</span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body"></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ViewerSkeleton
|
||||
|
|
@ -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!)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
5
resources/assets/src/fonts/minecraft.css
Normal file
5
resources/assets/src/fonts/minecraft.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@font-face {
|
||||
font-family: Minecraft;
|
||||
src: url('./minecraft.woff2') format('woff2'),
|
||||
url('./minecraft.woff') format('woff');
|
||||
}
|
||||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
49
resources/assets/src/index.tsx
Normal file
49
resources/assets/src/index.tsx
Normal file
|
|
@ -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 = () => (
|
||||
<React.Suspense fallback={route.frame?.() ?? ''}>
|
||||
<Component />
|
||||
</React.Suspense>
|
||||
)
|
||||
const c =
|
||||
typeof route.el === 'string'
|
||||
? document.querySelector(route.el)
|
||||
: route.el
|
||||
ReactDOM.render(<Root />, 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import './i18n'
|
|||
import './net'
|
||||
import './event'
|
||||
import './notification'
|
||||
import './emailVerification'
|
||||
import './logout'
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
|
|
|
|||
9
resources/assets/src/scripts/emailVerification.tsx
Normal file
9
resources/assets/src/scripts/emailVerification.tsx
Normal file
|
|
@ -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(<EmailVerification />, container)
|
||||
}
|
||||
|
|
@ -1,12 +1,20 @@
|
|||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
const bus: { [name: string]: CallableFunction[] } = Object.create(null)
|
||||
const bus = new Map<string | symbol, Set<CallableFunction>>()
|
||||
|
||||
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 }
|
||||
|
|
|
|||
33
resources/assets/src/scripts/hooks/useTexture.ts
Normal file
33
resources/assets/src/scripts/hooks/useTexture.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import * as fetch from '../net'
|
||||
import { Texture, TextureType } from '../types'
|
||||
|
||||
type Response = fetch.ResponseBody<Texture>
|
||||
|
||||
export default function useTexture(): [
|
||||
{ url: string; type: TextureType },
|
||||
React.Dispatch<React.SetStateAction<number>>,
|
||||
] {
|
||||
const [tid, setTid] = useState(0)
|
||||
const [url, setUrl] = useState('')
|
||||
const [type, setType] = useState<TextureType>('steve')
|
||||
|
||||
useEffect(() => {
|
||||
if (tid <= 0) {
|
||||
setUrl('')
|
||||
return
|
||||
}
|
||||
|
||||
const getTexture = async () => {
|
||||
const {
|
||||
data: { hash, type },
|
||||
} = await fetch.get<Response>(`/skinlib/info/${tid}`)
|
||||
|
||||
setUrl(`${blessing.base_url}/textures/${hash}`)
|
||||
setType(type)
|
||||
}
|
||||
getTexture()
|
||||
}, [tid])
|
||||
|
||||
return [{ url, type }, setTid]
|
||||
}
|
||||
24
resources/assets/src/scripts/hooks/useTween.ts
Normal file
24
resources/assets/src/scripts/hooks/useTween.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import TWEEN from '@tweenjs/tween.js'
|
||||
|
||||
export default function useTween<T = any>(
|
||||
initialValue: T,
|
||||
): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
const [value, setValue] = useState<T>(initialValue)
|
||||
const ref = useRef<T>(value)
|
||||
const [dest, setDest] = useState<T>(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]
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
const chooser = document.querySelector<HTMLInputElement>('#language-chooser')
|
||||
chooser?.addEventListener('change', () => {
|
||||
window.location.href = `?lang=${chooser.value}`
|
||||
})
|
||||
|
|
@ -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<ModalResult> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
}
|
||||
27
resources/assets/src/scripts/modal.tsx
Normal file
27
resources/assets/src/scripts/modal.tsx
Normal file
|
|
@ -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<ModalResult> {
|
||||
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(
|
||||
<Modal
|
||||
{...options}
|
||||
show
|
||||
center
|
||||
onConfirm={resolve}
|
||||
onDismiss={reject}
|
||||
onClose={handleClose}
|
||||
/>,
|
||||
container,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -4,9 +4,10 @@ import { queryStringify } from './utils'
|
|||
import { showModal } from './notify'
|
||||
import { trans } from './i18n'
|
||||
|
||||
export interface ResponseBody {
|
||||
export interface ResponseBody<T = null> {
|
||||
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<HTMLMetaElement>('meta[name="csrf-token"]')
|
||||
const csrfField = document.querySelector<HTMLMetaElement>(
|
||||
'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<any> {
|
|||
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<any> {
|
|||
|
||||
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<any> {
|
|||
}
|
||||
|
||||
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('<br>')
|
||||
message = `${message}<br><details>${trace}</details>`
|
||||
|
|
@ -86,7 +91,7 @@ export async function walkFetch(request: Request): Promise<any> {
|
|||
}
|
||||
}
|
||||
|
||||
export function get(url: string, params = empty): Promise<any> {
|
||||
export function get<T = any>(url: string, params = empty): Promise<T> {
|
||||
emit('beforeFetch', {
|
||||
method: 'GET',
|
||||
url,
|
||||
|
|
@ -95,10 +100,12 @@ export function get(url: string, params = empty): Promise<any> {
|
|||
|
||||
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<any> {
|
||||
function nonGet<T = any>(method: string, url: string, data: any): Promise<T> {
|
||||
emit('beforeFetch', {
|
||||
method: method.toUpperCase(),
|
||||
url,
|
||||
|
|
@ -118,26 +125,32 @@ function nonGet(method: string, url: string, data: any): Promise<any> {
|
|||
return walkFetch(request)
|
||||
}
|
||||
|
||||
export function post(url: string, data = empty): Promise<any> {
|
||||
return nonGet('POST', url, data)
|
||||
export function post<T = any>(url: string, data = empty): Promise<T> {
|
||||
return nonGet<T>('POST', url, data)
|
||||
}
|
||||
|
||||
export function put(url: string, data = empty): Promise<any> {
|
||||
return nonGet('PUT', url, data)
|
||||
export function put<T = any>(url: string, data = empty): Promise<T> {
|
||||
return nonGet<T>('PUT', url, data)
|
||||
}
|
||||
|
||||
export function del(url: string, data = empty): Promise<any> {
|
||||
return nonGet('DELETE', url, data)
|
||||
export function del<T = any>(url: string, data = empty): Promise<T> {
|
||||
return nonGet<T>('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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}<br><small>${time}</small>`,
|
||||
})
|
||||
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)
|
||||
}
|
||||
8
resources/assets/src/scripts/notification.tsx
Normal file
8
resources/assets/src/scripts/notification.tsx
Normal file
|
|
@ -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(<NotificationsList />, container)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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: () => (
|
||||
<div className="card card-primary card-outline">
|
||||
<div className="card-header"> </div>
|
||||
<div className="card-body"></div>
|
||||
<div className="card-footer"> </div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: () => (
|
||||
<div className="card">
|
||||
<div className="card-header"> </div>
|
||||
<div className="card-body p-0"></div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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',
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import $ from 'jquery'
|
||||
import 'bootstrap'
|
||||
|
||||
const toastIcons: Record<ToastType, string> = {
|
||||
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)
|
||||
}
|
||||
}
|
||||
78
resources/assets/src/scripts/toast.tsx
Normal file
78
resources/assets/src/scripts/toast.tsx
Normal file
|
|
@ -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<ToastQueue>([])
|
||||
|
||||
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) => (
|
||||
<ToastBox
|
||||
key={el.id}
|
||||
type={el.type}
|
||||
distance={50 + i * 70}
|
||||
onClose={() => handleClose(el.id)}
|
||||
>
|
||||
{el.message}
|
||||
</ToastBox>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export class Toast {
|
||||
constructor() {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
ReactDOM.render(<ToastContainer />, 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)
|
||||
}
|
||||
}
|
||||
24
resources/assets/src/scripts/types.ts
Normal file
24
resources/assets/src/scripts/types.ts
Normal file
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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('&')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export function truthy(message: string) {
|
||||
return (value?: unknown): string | void => {
|
||||
return (value?: unknown): string | undefined => {
|
||||
if (!value) {
|
||||
return message
|
||||
}
|
||||
|
|
|
|||
11
resources/assets/src/shims.d.ts
vendored
11
resources/assets/src/shims.d.ts
vendored
|
|
@ -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<object>
|
||||
|
|
|
|||
27
resources/assets/src/styles/_breakpoints.scss
Normal file
27
resources/assets/src/styles/_breakpoints.scss
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
5
resources/assets/src/styles/_utils.scss
Normal file
5
resources/assets/src/styles/_utils.scss
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@mixin truncate-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
15
resources/assets/src/styles/spectre.css
Normal file
15
resources/assets/src/styles/spectre.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
body {
|
||||
height: 97vh;
|
||||
}
|
||||
|
||||
.hero {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: Minecraft;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
<template>
|
||||
<div class="container-fluid d-flex flex-wrap">
|
||||
<div v-for="(plugin, index) in plugins" :key="plugin.name" class="info-box mr-3">
|
||||
<span class="info-box-icon" :class="`bg-${plugin.icon.bg}`">
|
||||
<i :class="`${plugin.icon.faType} fa-${plugin.icon.fa}`" />
|
||||
</span>
|
||||
<div class="info-box-content">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<input :checked="plugin.enabled" type="checkbox" @click.prevent="switchPlugin(plugin, $event)">
|
||||
<strong>{{ plugin.title }}</strong>
|
||||
<span class="text-gray">v{{ plugin.version }}</span>
|
||||
</div>
|
||||
<div class="plugin-actions">
|
||||
<a
|
||||
v-if="plugin.readme"
|
||||
:href="`${baseUrl}/admin/plugins/readme/${plugin.name}`"
|
||||
>
|
||||
<i class="fas fa-question" />
|
||||
</a>
|
||||
<a
|
||||
v-if="plugin.enabled && plugin.config"
|
||||
:href="`${baseUrl}/admin/plugins/config/${plugin.name}`"
|
||||
>
|
||||
<i class="fas fa-cog" />
|
||||
</a>
|
||||
<a href="#" @click="deletePlugin(plugin, index)">
|
||||
<i class="fas fa-trash" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 plugin-desc" :title="plugin.description">
|
||||
{{ plugin.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import alertUnresolvedPlugins from '../../components/mixins/alertUnresolvedPlugins'
|
||||
import emitMounted from '../../components/mixins/emitMounted'
|
||||
import { showModal, toast } from '../../scripts/notify'
|
||||
|
||||
export default {
|
||||
name: 'Plugins',
|
||||
mixins: [
|
||||
emitMounted,
|
||||
],
|
||||
props: {
|
||||
baseUrl: {
|
||||
type: String,
|
||||
default: blessing.base_url,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
plugins: [],
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.plugins = await this.$http.get('/admin/plugins/data')
|
||||
},
|
||||
async switchPlugin(plugin, { target }) {
|
||||
if (target.checked) {
|
||||
if (await this.enablePlugin(plugin.name)) {
|
||||
plugin.enabled = true
|
||||
}
|
||||
} else if (await this.disablePlugin(plugin.name)) {
|
||||
plugin.enabled = false
|
||||
}
|
||||
},
|
||||
async enablePlugin(name) {
|
||||
const {
|
||||
code, message, data: { reason } = { reason: [] },
|
||||
} = await this.$http.post(
|
||||
'/admin/plugins/manage',
|
||||
{ action: 'enable', name },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
} else {
|
||||
alertUnresolvedPlugins(message, reason)
|
||||
}
|
||||
|
||||
return code === 0
|
||||
},
|
||||
async disablePlugin(name) {
|
||||
const { code, message } = await this.$http.post(
|
||||
'/admin/plugins/manage',
|
||||
{ action: 'disable', name },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
|
||||
return code === 0
|
||||
},
|
||||
async deletePlugin(plugin, index) {
|
||||
try {
|
||||
await showModal({
|
||||
title: plugin.title,
|
||||
text: this.$t('admin.confirmDeletion'),
|
||||
okButtonType: 'danger',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const { code, message } = await this.$http.post(
|
||||
'/admin/plugins/manage',
|
||||
{ action: 'delete', name: plugin.name },
|
||||
)
|
||||
if (code === 0) {
|
||||
this.$delete(this.plugins, index)
|
||||
toast.success(message)
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus">
|
||||
.info-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 .5rem 1rem rgba(0,0,0,.15)
|
||||
|
||||
.info-box-content
|
||||
max-width 85%
|
||||
|
||||
.plugin-actions
|
||||
margin-top -7px
|
||||
a
|
||||
transition-property color
|
||||
transition-duration 0.3s
|
||||
color #000
|
||||
&:hover
|
||||
color #999
|
||||
&:not(:last-child)
|
||||
margin-right 7px
|
||||
|
||||
.plugin-desc
|
||||
font-size 14px
|
||||
white-space nowrap
|
||||
overflow hidden
|
||||
text-overflow ellipsis
|
||||
</style>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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> = props => {
|
||||
const { plugin } = props
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (event.target.checked) {
|
||||
props.onEnable(plugin)
|
||||
} else {
|
||||
props.onDisable(plugin)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = () => props.onDelete(plugin)
|
||||
|
||||
return (
|
||||
<div className={`info-box mr-3 ${styles.box}`}>
|
||||
<span className={`info-box-icon bg-${plugin.icon.bg}`}>
|
||||
<i className={`${plugin.icon.faType} fa-${plugin.icon.fa}`} />
|
||||
</span>
|
||||
<div className={`info-box-content ${styles.content}`}>
|
||||
<div className="d-flex justify-content-between">
|
||||
<div>
|
||||
<input
|
||||
className="mr-2"
|
||||
type="checkbox"
|
||||
checked={plugin.enabled}
|
||||
title={
|
||||
plugin.enabled
|
||||
? trans('admin.disablePlugin')
|
||||
: trans('admin.enablePlugin')
|
||||
}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<strong className="mr-2">{plugin.title}</strong>
|
||||
<span className="text-gray">v{plugin.version}</span>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{plugin.readme && (
|
||||
<a
|
||||
href={`${props.baseUrl}/admin/plugins/readme/${plugin.name}`}
|
||||
title={trans('admin.pluginReadme')}
|
||||
>
|
||||
<i className="fas fa-question" />
|
||||
</a>
|
||||
)}
|
||||
{plugin.enabled && plugin.config && (
|
||||
<a
|
||||
href={`${props.baseUrl}/admin/plugins/config/${plugin.name}`}
|
||||
title={trans('admin.configurePlugin')}
|
||||
>
|
||||
<i className="fas fa-cog" />
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="#"
|
||||
title={trans('admin.deletePlugin')}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<i className="fas fa-trash" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-2 ${styles.description}`}>{plugin.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(InfoBox)
|
||||
114
resources/assets/src/views/admin/PluginsManagement/index.tsx
Normal file
114
resources/assets/src/views/admin/PluginsManagement/index.tsx
Normal file
|
|
@ -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<Plugin[]>([])
|
||||
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: (
|
||||
<div>
|
||||
<p>{message}</p>
|
||||
<ul>
|
||||
{reason.map((t, i) => (
|
||||
<li key={i}>{t}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className="d-flex flex-wrap">
|
||||
{plugins.map((plugin, i) => (
|
||||
<InfoBox
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
onEnable={plugin => handleEnable(plugin, i)}
|
||||
onDisable={plugin => handleDisable(plugin, i)}
|
||||
onDelete={handleDelete}
|
||||
baseUrl={blessing.base_url}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default hot(PluginsManagement)
|
||||
10
resources/assets/src/views/admin/PluginsManagement/types.ts
Normal file
10
resources/assets/src/views/admin/PluginsManagement/types.ts
Normal file
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<div v-if="players.length">
|
||||
<p v-t="'user.bindExistedPlayer'" />
|
||||
<div class="form-group mb-3">
|
||||
<select v-model="selected" class="form-control player-select">
|
||||
<option v-for="name in players" :key="name">{{ name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p v-t="'user.bindNewPlayer'" />
|
||||
<div class="form-group mb-3">
|
||||
<input
|
||||
v-model="selected"
|
||||
class="form-control"
|
||||
:placeholder="$t('general.player.player-name')"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="message" class="alert alert-warning" v-text="message" />
|
||||
|
||||
<button class="btn btn-primary float-right" type="submit" :disabled="pending">
|
||||
<template v-if="pending">
|
||||
<i class="fa fa-spinner fa-spin" /> {{ $t('general.wait') }}
|
||||
</template>
|
||||
<span v-else>{{ $t('general.submit') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import emitMounted from '../../components/mixins/emitMounted'
|
||||
import { showModal } from '../../scripts/notify'
|
||||
|
||||
export default {
|
||||
name: 'BindPlayer',
|
||||
mixins: [
|
||||
emitMounted,
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
players: [],
|
||||
selected: '',
|
||||
pending: false,
|
||||
message: '',
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchPlayers()
|
||||
},
|
||||
methods: {
|
||||
async fetchPlayers() {
|
||||
const players = (await this.$http.get('/user/player/list')).data
|
||||
this.players = players.map(player => player.name)
|
||||
this.selected = this.players[0]
|
||||
},
|
||||
async submit() {
|
||||
this.pending = true
|
||||
const { code, message } = await this.$http.post(
|
||||
'/user/player/bind',
|
||||
{ player: this.selected },
|
||||
)
|
||||
this.pending = false
|
||||
if (code === 0) {
|
||||
await showModal({ mode: 'alert', text: message })
|
||||
window.location.href = `${blessing.base_url}/user`
|
||||
} else {
|
||||
this.message = message
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus">
|
||||
@import "../../styles/auth.styl"
|
||||
|
||||
.player-select
|
||||
width 100%
|
||||
</style>
|
||||
98
resources/assets/src/views/user/BindPlayers.tsx
Normal file
98
resources/assets/src/views/user/BindPlayers.tsx
Normal file
|
|
@ -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<string[]>([])
|
||||
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<fetch.ResponseBody<Player[]>>(
|
||||
'/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<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setIsPending(true)
|
||||
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
'/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 ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<form method="post" onSubmit={handleSubmit}>
|
||||
{players.length > 0 ? (
|
||||
<>
|
||||
<p>{t('user.bindExistedPlayer')}</p>
|
||||
<div className="mb-3">
|
||||
{players.map(player => (
|
||||
<label className="d-block mb-1">
|
||||
<input
|
||||
key={player}
|
||||
type="radio"
|
||||
className="mr-2"
|
||||
checked={selected === player}
|
||||
onChange={() => setSelected(player)}
|
||||
/>
|
||||
{player}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>{t('user.bindNewPlayer')}</p>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control mb-3"
|
||||
placeholder={t('general.player.player-name')}
|
||||
onChange={e => setSelected(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary float-right"
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<i className="fas fa-spinner fa-spin mr-1"></i>
|
||||
{t('general.wait')}
|
||||
</>
|
||||
) : (
|
||||
t('general.submit')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default hot(BindPlayers)
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<portal selector="#email-verification" :disabled="disablePortal">
|
||||
<email-verification />
|
||||
</portal>
|
||||
|
||||
<portal selector="#closet-list" :disabled="disablePortal">
|
||||
<div class="card card-primary card-tabs">
|
||||
<div class="card-header p-0 pt-1 pl-1">
|
||||
<div class="d-flex justify-content-between">
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link"
|
||||
:class="{ active: category === 'skin' }"
|
||||
href="#"
|
||||
data-toggle="pill"
|
||||
role="tab"
|
||||
@click="switchCategory"
|
||||
>
|
||||
{{ $t('general.skin') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link"
|
||||
:class="{ active: category === 'cape' }"
|
||||
href="#"
|
||||
@click="switchCategory"
|
||||
>
|
||||
{{ $t('general.cape') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item d-none d-md-block">
|
||||
<a
|
||||
v-t="'user.closet.upload'"
|
||||
:href="`${baseUrl}/skinlib/upload`"
|
||||
class="nav-link"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mr-3 my-2 my-lg-0">
|
||||
<input
|
||||
v-model="query"
|
||||
class="form-control mr-sm-2"
|
||||
type="search"
|
||||
aria-label="Search"
|
||||
:placeholder="$t('user.typeToSearch')"
|
||||
@input="search"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div
|
||||
v-if="category === 'skin'"
|
||||
id="skin-category"
|
||||
:class="{ active: category === 'skin' }"
|
||||
>
|
||||
<div v-if="skinItems.length === 0" class="text-center p-3">
|
||||
<div v-if="query !== ''" v-t="'general.noResult'" />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-else v-html="$t('user.emptyClosetMsg', { url: linkToSkin })" />
|
||||
</div>
|
||||
<div v-else class="d-flex flex-wrap">
|
||||
<closet-item
|
||||
v-for="(item, index) in skinItems"
|
||||
:key="item.tid"
|
||||
:tid="item.tid"
|
||||
:name="item.name"
|
||||
:type="item.type"
|
||||
:selected="selectedSkin === item.tid"
|
||||
@select="selectTexture(item.tid)"
|
||||
@item-removed="removeSkinItem(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
id="cape-category"
|
||||
:class="{ active: category === 'cape' }"
|
||||
>
|
||||
<div v-if="capeItems.length === 0" class="text-center p-3">
|
||||
<div v-if="query !== ''" v-t="'general.noResult'" />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-else v-html="$t('user.emptyClosetMsg', { url: linkToCape })" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<closet-item
|
||||
v-for="(item, index) in capeItems"
|
||||
:key="item.tid"
|
||||
:tid="item.tid"
|
||||
:name="item.name"
|
||||
:type="item.type"
|
||||
:selected="selectedCape === item.tid"
|
||||
@select="selectTexture(item.tid)"
|
||||
@item-removed="removeCapeItem(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<paginate
|
||||
v-if="category === 'skin'"
|
||||
v-model="skinCurrentPage"
|
||||
:page-count="skinTotalPages"
|
||||
class="float-right"
|
||||
container-class="pagination pagination-sm no-margin"
|
||||
page-class="page-item"
|
||||
page-link-class="page-link"
|
||||
prev-class="page-item"
|
||||
prev-link-class="page-link"
|
||||
next-class="page-item"
|
||||
next-link-class="page-link"
|
||||
first-button-text="«"
|
||||
prev-text="‹"
|
||||
next-text="›"
|
||||
last-button-text="»"
|
||||
:click-handler="pageChanged"
|
||||
:first-last-button="true"
|
||||
/>
|
||||
<paginate
|
||||
v-else
|
||||
v-model="capeCurrentPages"
|
||||
:page-count="capeTotalPages"
|
||||
class="float-right"
|
||||
container-class="pagination pagination-sm no-margin"
|
||||
page-class="page-item"
|
||||
page-link-class="page-link"
|
||||
prev-class="page-item"
|
||||
prev-link-class="page-link"
|
||||
next-class="page-item"
|
||||
next-link-class="page-link"
|
||||
first-button-text="«"
|
||||
prev-text="‹"
|
||||
next-text="›"
|
||||
last-button-text="»"
|
||||
:click-handler="pageChanged"
|
||||
:first-last-button="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</portal>
|
||||
|
||||
<portal selector="#previewer" :disabled="disablePortal">
|
||||
<previewer
|
||||
closet-mode
|
||||
:skin="skinUrl"
|
||||
:cape="capeUrl"
|
||||
:model="model"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="d-flex justify-content-between">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-toggle="modal"
|
||||
data-target="#modal-use-as"
|
||||
@click="fetchPlayersList"
|
||||
>
|
||||
{{ $t('user.useAs') }}
|
||||
</button>
|
||||
<button class="btn btn-default" data-test="resetSelected" @click="resetSelected">
|
||||
{{ $t('user.resetSelected') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</previewer>
|
||||
</portal>
|
||||
|
||||
<portal selector="#modals" :disabled="disablePortal">
|
||||
<apply-to-player-dialog ref="useAs" :skin="selectedSkin" :cape="selectedCape" />
|
||||
<add-player-dialog @add="fetchPlayersList" />
|
||||
</portal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Paginate from 'vuejs-paginate'
|
||||
import { debounce, queryString } from '../../scripts/utils'
|
||||
import Portal from '../../components/Portal'
|
||||
import ClosetItem from '../../components/ClosetItem.vue'
|
||||
import EmailVerification from '../../components/EmailVerification.vue'
|
||||
import AddPlayerDialog from '../../components/AddPlayerDialog.vue'
|
||||
import ApplyToPlayerDialog from '../../components/ApplyToPlayerDialog.vue'
|
||||
import emitMounted from '../../components/mixins/emitMounted'
|
||||
|
||||
export default {
|
||||
name: 'Closet',
|
||||
components: {
|
||||
Portal,
|
||||
Paginate,
|
||||
ClosetItem,
|
||||
Previewer: () => import('../../components/Previewer.vue'),
|
||||
EmailVerification,
|
||||
AddPlayerDialog,
|
||||
ApplyToPlayerDialog,
|
||||
},
|
||||
mixins: [
|
||||
emitMounted,
|
||||
],
|
||||
props: {
|
||||
baseUrl: {
|
||||
type: String,
|
||||
default: blessing.base_url,
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
category: 'skin',
|
||||
query: '',
|
||||
skinItems: [],
|
||||
skinCurrentPage: 1,
|
||||
skinTotalPages: 1,
|
||||
capeItems: [],
|
||||
capeCurrentPages: 1,
|
||||
capeTotalPages: 1,
|
||||
selectedSkin: 0,
|
||||
skinUrl: '',
|
||||
model: 'steve',
|
||||
selectedCape: 0,
|
||||
capeUrl: '',
|
||||
linkToSkin: `${blessing.base_url}/skinlib?filter=skin`,
|
||||
linkToCape: `${blessing.base_url}/skinlib?filter=cape`,
|
||||
disablePortal: process.env.NODE_ENV === 'test',
|
||||
}),
|
||||
created() {
|
||||
this.search = debounce(this.loadCloset, 350)
|
||||
},
|
||||
beforeMount() {
|
||||
this.loadCloset()
|
||||
},
|
||||
mounted() {
|
||||
const tid = +queryString('tid', 0)
|
||||
if (tid) {
|
||||
this.selectTexture(tid)
|
||||
this.fetchPlayersList()
|
||||
$('#modal-use-as').modal()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/* istanbul ignore next */
|
||||
search() {}, // eslint-disable-line no-empty-function
|
||||
async loadCloset(page = 1) {
|
||||
const {
|
||||
data: {
|
||||
items, category, total_pages: totalPages,
|
||||
},
|
||||
} = await this.$http.get(
|
||||
'/user/closet/list',
|
||||
{
|
||||
category: this.category,
|
||||
q: this.query,
|
||||
page,
|
||||
},
|
||||
)
|
||||
this[`${category}TotalPages`] = totalPages
|
||||
this[`${category}Items`] = items
|
||||
this[`${category}CurrentPages`] = page
|
||||
},
|
||||
removeSkinItem(index) {
|
||||
this.$delete(this.skinItems, index)
|
||||
},
|
||||
removeCapeItem(index) {
|
||||
this.$delete(this.capeItems, index)
|
||||
},
|
||||
switchCategory() {
|
||||
this.category = this.category === 'skin' ? 'cape' : 'skin'
|
||||
this.loadCloset(this[`${this.category}CurrentPages`])
|
||||
},
|
||||
pageChanged(page) {
|
||||
this.loadCloset(page)
|
||||
},
|
||||
async selectTexture(tid) {
|
||||
const { data: { type, hash } } = await this.$http.get(`/skinlib/info/${tid}`)
|
||||
if (type === 'cape') {
|
||||
this.capeUrl = `/textures/${hash}`
|
||||
this.selectedCape = tid
|
||||
} else {
|
||||
this.skinUrl = `/textures/${hash}`
|
||||
this.selectedSkin = tid
|
||||
this.model = type
|
||||
}
|
||||
},
|
||||
resetSelected() {
|
||||
this.selectedSkin = 0
|
||||
this.selectedCape = 0
|
||||
this.skinUrl = ''
|
||||
this.capeUrl = ''
|
||||
},
|
||||
fetchPlayersList() {
|
||||
this.$refs.useAs.fetchList()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -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;
|
||||
}
|
||||
98
resources/assets/src/views/user/Closet/ClosetItem.tsx
Normal file
98
resources/assets/src/views/user/Closet/ClosetItem.tsx
Normal file
|
|
@ -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> = 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<fetch.ResponseBody>(
|
||||
'/user/profile/avatar',
|
||||
{ tid: item.tid },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
document
|
||||
.querySelectorAll<HTMLImageElement>('[alt="User Image"]')
|
||||
.forEach(el => (el.src += `?${new Date().getTime()}`))
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`card mr-3 mb-3 ${styles.item} ${
|
||||
props.selected ? 'shadow' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`card-body ${styles.bg}`} onClick={handleItemClick}>
|
||||
<img
|
||||
src={`${blessing.base_url}/preview/${item.tid}?height=150`}
|
||||
alt={item.pivot.item_name}
|
||||
className="card-img-top"
|
||||
/>
|
||||
</div>
|
||||
<div className="card-footer pb-2 pt-2 pl-1 pr-1">
|
||||
<div className="container d-flex justify-content-between">
|
||||
<span className={styles.truncate} title={item.pivot.item_name}>
|
||||
{item.pivot.item_name}
|
||||
</span>
|
||||
<span className="d-inline-block drop-down">
|
||||
<span
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i className={`fas fa-cog text-gray ${styles.icon}`} />
|
||||
</span>
|
||||
<div className="dropdown-menu">
|
||||
<a href="#" className="dropdown-item" onClick={props.onRename}>
|
||||
{t('user.renameItem')}
|
||||
</a>
|
||||
<a href="#" className="dropdown-item" onClick={props.onRemove}>
|
||||
{t('user.removeItem')}
|
||||
</a>
|
||||
<a
|
||||
href={`${blessing.base_url}/skinlib/show/${item.tid}`}
|
||||
className="dropdown-item"
|
||||
target="_blank"
|
||||
>
|
||||
{t('user.viewInSkinlib')}
|
||||
</a>
|
||||
<a href="#" className="dropdown-item" onClick={setAsAvatar}>
|
||||
{t('user.setAsAvatar')}
|
||||
</a>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClosetItem
|
||||
108
resources/assets/src/views/user/Closet/ModalApply.tsx
Normal file
108
resources/assets/src/views/user/Closet/ModalApply.tsx
Normal file
|
|
@ -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> = props => {
|
||||
const [players, setPlayers] = useState<Player[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.show) {
|
||||
return
|
||||
}
|
||||
|
||||
const getPlayers = async () => {
|
||||
setIsLoading(true)
|
||||
const { data } = await fetch.get<fetch.ResponseBody<Player[]>>(
|
||||
'/user/player/list',
|
||||
)
|
||||
setPlayers(data)
|
||||
setIsLoading(false)
|
||||
}
|
||||
getPlayers()
|
||||
}, [props.show])
|
||||
|
||||
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(event.target.value)
|
||||
}
|
||||
|
||||
const handleSelect = async (player: Player) => {
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
`/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 (
|
||||
<Modal
|
||||
show={props.show}
|
||||
id="modal-apply"
|
||||
title={t('user.closet.use-as.title')}
|
||||
flexFooter
|
||||
footer={<></>}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : players.length === 0 ? (
|
||||
<p>{t('user.closet.use-as.empty')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('user.typeToSearch')}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
{players
|
||||
.filter(player => player.name.includes(search))
|
||||
.map(player => (
|
||||
<button
|
||||
key={player.pid}
|
||||
className="btn btn-block btn-outline-info text-left"
|
||||
title={player.name}
|
||||
onClick={() => handleSelect(player)}
|
||||
>
|
||||
<img
|
||||
src={`${baseUrl}/avatar/${player.tid_skin}?3d&size=45`}
|
||||
alt={player.name}
|
||||
width={45}
|
||||
height={45}
|
||||
/>
|
||||
<span className="ml-1">{player.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalApply
|
||||
38
resources/assets/src/views/user/Closet/Previewer.tsx
Normal file
38
resources/assets/src/views/user/Closet/Previewer.tsx
Normal file
|
|
@ -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> = 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(
|
||||
<React.Suspense fallback={<ViewerSkeleton />}>
|
||||
<Viewer skin={skin} cape={cape} showIndicator>
|
||||
{props.children}
|
||||
</Viewer>
|
||||
</React.Suspense>,
|
||||
container,
|
||||
)
|
||||
}
|
||||
|
||||
export default Previewer
|
||||
275
resources/assets/src/views/user/Closet/index.tsx
Normal file
275
resources/assets/src/views/user/Closet/index.tsx
Normal file
|
|
@ -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(
|
||||
<T extends unknown>(
|
||||
value: React.SetStateAction<T>,
|
||||
setter: React.Dispatch<React.SetStateAction<T>>,
|
||||
) => setter(value),
|
||||
350,
|
||||
)
|
||||
|
||||
const Closet: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [category, setCategory] = useState<Category>('skin')
|
||||
const [search, setSearch] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
const [skin, setSkin] = useState<Texture | null>(null)
|
||||
const [cape, setCape] = useState<Texture | null>(null)
|
||||
const [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<HTMLInputElement>) => {
|
||||
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<fetch.ResponseBody>(
|
||||
`/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<fetch.ResponseBody>(
|
||||
`/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 (
|
||||
<>
|
||||
<div className="card card-primary card-tabs">
|
||||
<div className="card-header p-0 pt-1 pl-1">
|
||||
<div className="d-flex justify-content-between">
|
||||
<ul className="nav nav-tabs" role="tablist">
|
||||
<li className="nav-item">
|
||||
<a
|
||||
href="#"
|
||||
className={`nav-link ${category === 'skin' ? 'active' : ''}`}
|
||||
data-toggle="pill"
|
||||
role="tab"
|
||||
onClick={switchCategory}
|
||||
>
|
||||
{t('general.skin')}
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a
|
||||
href="#"
|
||||
className={`nav-link ${category === 'cape' ? 'active' : ''}`}
|
||||
data-toggle="pill"
|
||||
role="tab"
|
||||
onClick={switchCategory}
|
||||
>
|
||||
{t('general.cape')}
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item d-none d-md-block">
|
||||
<a
|
||||
href={`${blessing.base_url}/skinlib/upload`}
|
||||
className="nav-link"
|
||||
>
|
||||
{t('user.closet.upload')}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mr-3 my-2 my-lg-0">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
className="form-control mr-sm-2"
|
||||
aria-label="Search"
|
||||
placeholder={t('user.typeToSearch')}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center p-3">
|
||||
{search ? (
|
||||
t('general.noResult')
|
||||
) : (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('user.emptyClosetMsg', {
|
||||
url: `${blessing.base_url}/skinlib?filter=${category}`,
|
||||
}),
|
||||
}}
|
||||
></span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="d-flex flex-wrap">
|
||||
{items.map((item, i) => (
|
||||
<ClosetItem
|
||||
key={item.tid}
|
||||
item={item}
|
||||
selected={isSelected(item)}
|
||||
onClick={handleSelect}
|
||||
onRename={() => renameItem(item, i)}
|
||||
onRemove={() => removeItem(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<div className="float-right">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Previewer
|
||||
skin={skin?.hash}
|
||||
cape={cape?.hash}
|
||||
isAlex={skin?.type === 'alex'}
|
||||
>
|
||||
<div className="d-flex justify-content-between">
|
||||
<button className="btn btn-primary" onClick={applyToPlayer}>
|
||||
{t('user.useAs')}
|
||||
</button>
|
||||
<button className="btn btn-default" onClick={resetSelected}>
|
||||
{t('user.resetSelected')}
|
||||
</button>
|
||||
</div>
|
||||
</Previewer>
|
||||
<ModalApply
|
||||
show={showModalApply}
|
||||
canAdd
|
||||
skin={skin?.tid}
|
||||
cape={cape?.tid}
|
||||
onClose={() => setShowModalApply(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default hot(Closet)
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<email-verification />
|
||||
<div class="card card-primary card-outline">
|
||||
<div class="card-header">
|
||||
<h3 v-t="'user.used.title'" class="card-title" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-1" />
|
||||
<div class="col-md-6">
|
||||
<div class="info-box bg-teal">
|
||||
<span class="info-box-icon">
|
||||
<i class="fas fa-gamepad" />
|
||||
</span>
|
||||
<div class="info-box-content">
|
||||
<span class="info-box-text">{{ $t('user.used.players') }}</span>
|
||||
<span class="info-box-number">
|
||||
<strong>{{ playersUsed }}</strong> / {{ playersTotal }}
|
||||
</span>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :style="{ width: playersPercentage + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-box bg-maroon">
|
||||
<span class="info-box-icon">
|
||||
<i class="fas fa-hdd" />
|
||||
</span>
|
||||
<div class="info-box-content">
|
||||
<span class="info-box-text">{{ $t('user.used.storage') }}</span>
|
||||
<span class="info-box-number">
|
||||
<template v-if="storageUsed > 1024">
|
||||
<strong>{{ ~~(storageUsed / 1024) }}</strong> / {{ ~~(storageTotal / 1024) }} MB
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong>{{ storageUsed }}</strong> / {{ storageTotal }} KB
|
||||
</template>
|
||||
</span>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :style="{ width: storagePercentage + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<p class="text-center score-title">
|
||||
<strong v-t="'user.cur-score'" />
|
||||
</p>
|
||||
<p id="score" data-toggle="modal" data-target="#modal-score-instruction">
|
||||
{{ animatedScore }}
|
||||
</p>
|
||||
<p v-t="'user.score-notice'" class="text-center score-notice" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button
|
||||
v-if="canSign"
|
||||
class="btn bg-gradient-primary pl-5 pr-5"
|
||||
:disabled="signing"
|
||||
@click="sign"
|
||||
>
|
||||
<i class="far fa-calendar-check" aria-hidden="true" /> {{ $t('user.sign') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn bg-gradient-primary pl-4 pr-4"
|
||||
:title="$t('user.last-sign', { time: lastSignAt.toLocaleString() })"
|
||||
disabled
|
||||
>
|
||||
<i class="far fa-calendar-check" aria-hidden="true" />
|
||||
{{ remainingTimeText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Tween from '@tweenjs/tween.js'
|
||||
import EmailVerification from '../../components/EmailVerification.vue'
|
||||
import emitMounted from '../../components/mixins/emitMounted'
|
||||
import { toast } from '../../scripts/notify'
|
||||
|
||||
const ONE_DAY = 24 * 3600 * 1000
|
||||
|
||||
export default {
|
||||
name: 'Dashboard',
|
||||
components: {
|
||||
EmailVerification,
|
||||
},
|
||||
mixins: [
|
||||
emitMounted,
|
||||
],
|
||||
data: () => ({
|
||||
score: 0,
|
||||
tweenedScore: 0,
|
||||
lastSignAt: new Date(),
|
||||
signAfterZero: false,
|
||||
signGap: 0,
|
||||
signing: false,
|
||||
playersUsed: 0,
|
||||
playersTotal: 1,
|
||||
storageUsed: 0,
|
||||
storageTotal: 1,
|
||||
tween: null,
|
||||
}),
|
||||
computed: {
|
||||
playersPercentage() {
|
||||
return this.playersUsed / this.playersTotal * 100
|
||||
},
|
||||
storagePercentage() {
|
||||
return this.storageUsed / this.storageTotal * 100
|
||||
},
|
||||
signRemainingTime() {
|
||||
if (this.signAfterZero) {
|
||||
const today = new Date().setHours(0, 0, 0, 0)
|
||||
const tomorrow = today + ONE_DAY
|
||||
return this.lastSignAt.valueOf() < today ? 0 : tomorrow - Date.now()
|
||||
}
|
||||
return this.lastSignAt.valueOf() + this.signGap - Date.now()
|
||||
},
|
||||
remainingTimeText() {
|
||||
const time = this.signRemainingTime / 1000 / 60
|
||||
if (time < 60) {
|
||||
return this.$t(
|
||||
'user.sign-remain-time',
|
||||
{ time: ~~time, unit: this.$t('user.time-unit-min') },
|
||||
)
|
||||
}
|
||||
return this.$t(
|
||||
'user.sign-remain-time',
|
||||
{ time: ~~(time / 60), unit: this.$t('user.time-unit-hour') },
|
||||
)
|
||||
},
|
||||
canSign() {
|
||||
return this.signRemainingTime <= 0
|
||||
},
|
||||
animatedScore() {
|
||||
return this.tweenedScore.toFixed(0)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
score(newValue) {
|
||||
this.tween.to({ tweenedScore: newValue }, 1000).start()
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.tween = new Tween.Tween(this.$data)
|
||||
},
|
||||
beforeMount() {
|
||||
this.fetchScoreInfo()
|
||||
},
|
||||
mounted() {
|
||||
function animate() {
|
||||
requestAnimationFrame(animate)
|
||||
Tween.update()
|
||||
}
|
||||
animate()
|
||||
},
|
||||
methods: {
|
||||
async fetchScoreInfo() {
|
||||
const { data } = await this.$http.get('/user/score-info')
|
||||
this.lastSignAt = new Date(data.user.lastSignAt)
|
||||
this.signAfterZero = data.signAfterZero
|
||||
this.signGap = data.signGapTime * 3600 * 1000
|
||||
this.playersUsed = data.stats.players.used
|
||||
this.playersTotal = data.stats.players.total
|
||||
this.storageUsed = data.stats.storage.used
|
||||
this.storageTotal = data.stats.storage.total
|
||||
this.score = data.user.score
|
||||
},
|
||||
async sign() {
|
||||
this.signing = true
|
||||
const {
|
||||
code, message, data,
|
||||
} = await this.$http.post('/user/sign')
|
||||
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
this.score = data.score
|
||||
this.lastSignAt = new Date()
|
||||
this.storageUsed = data.storage.used
|
||||
this.storageTotal = data.storage.total
|
||||
} else {
|
||||
toast.warning(message)
|
||||
}
|
||||
this.signing = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus">
|
||||
.score-title
|
||||
margin-top 5px
|
||||
|
||||
@media (max-width 768px)
|
||||
margin-top 12px
|
||||
|
||||
#score
|
||||
font-family Minecraft
|
||||
font-size 50px
|
||||
text-align center
|
||||
margin-top 20px
|
||||
cursor help
|
||||
|
||||
.score-notice
|
||||
font-size smaller
|
||||
margin-top 20px
|
||||
</style>
|
||||
33
resources/assets/src/views/user/Dashboard/InfoBox.tsx
Normal file
33
resources/assets/src/views/user/Dashboard/InfoBox.tsx
Normal file
|
|
@ -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> = props => {
|
||||
const percentage = (props.used / props.total) * 100
|
||||
|
||||
return (
|
||||
<div className={`info-box bg-${props.color}`}>
|
||||
<span className="info-box-icon">
|
||||
<i className={`fas fa-${props.icon}`}></i>
|
||||
</span>
|
||||
<div className="info-box-content">
|
||||
<span className="info-box-text">{props.name}</span>
|
||||
<span className="info-box-number">
|
||||
<strong>{props.used}</strong> / {props.total} {props.unit}
|
||||
</span>
|
||||
<div className="progress">
|
||||
<div className="progress-bar" style={{ width: `${percentage}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(InfoBox)
|
||||
36
resources/assets/src/views/user/Dashboard/SignButton.tsx
Normal file
36
resources/assets/src/views/user/Dashboard/SignButton.tsx
Normal file
|
|
@ -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<HTMLButtonElement>
|
||||
}
|
||||
|
||||
const SignButton: React.FC<Props> = props => {
|
||||
const { lastSign, signGap, canSignAfterZero } = props
|
||||
const remainingTime = scoreUtils.remainingTime(
|
||||
lastSign,
|
||||
signGap,
|
||||
canSignAfterZero,
|
||||
)
|
||||
const remainingTimeText = scoreUtils.remainingTimeText(remainingTime)
|
||||
const canSign = remainingTime <= 0
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn bg-gradient-primary pl-4 pr-4"
|
||||
role="button"
|
||||
disabled={!canSign || props.isLoading}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<i className="far fa-calendar-check" aria-hidden="true" />
|
||||
{canSign ? trans('user.sign') : remainingTimeText}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SignButton)
|
||||
134
resources/assets/src/views/user/Dashboard/index.tsx
Normal file
134
resources/assets/src/views/user/Dashboard/index.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { hot } from 'react-hot-loader/root'
|
||||
import { trans } from '../../../scripts/i18n'
|
||||
import * as fetch from '../../../scripts/net'
|
||||
import { toast } from '../../../scripts/notify'
|
||||
import useTween from '../../../scripts/hooks/useTween'
|
||||
import InfoBox from './InfoBox'
|
||||
import SignButton from './SignButton'
|
||||
import scoreStyle from './score.scss'
|
||||
|
||||
type ScoreInfo = {
|
||||
signAfterZero: boolean
|
||||
signGapTime: number
|
||||
stats: { players: Stat; storage: Stat }
|
||||
user: { score: number; lastSignAt: string }
|
||||
}
|
||||
|
||||
type Stat = {
|
||||
used: number
|
||||
total: number
|
||||
}
|
||||
|
||||
type SignReturn = {
|
||||
score: number
|
||||
storage: Stat
|
||||
}
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [players, setPlayers] = useState<Stat>({ used: 0, total: 1 })
|
||||
const [storage, setStorage] = useState<Stat>({ used: 0, total: 1 })
|
||||
const [score, setScore] = useTween(0)
|
||||
const [lastSign, setLastSign] = useState(new Date())
|
||||
const [canSignAfterZero, setCanSignAfterZero] = useState(false)
|
||||
const [signGap, setSignGap] = useState(24)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInfo = async () => {
|
||||
setLoading(true)
|
||||
const { data } = await fetch.get<fetch.ResponseBody<ScoreInfo>>(
|
||||
'/user/score-info',
|
||||
)
|
||||
setPlayers(data.stats.players)
|
||||
setStorage(data.stats.storage)
|
||||
setScore(data.user.score)
|
||||
setLastSign(new Date(data.user.lastSignAt))
|
||||
setCanSignAfterZero(data.signAfterZero)
|
||||
setSignGap(data.signGapTime)
|
||||
setLoading(false)
|
||||
}
|
||||
fetchInfo()
|
||||
}, [])
|
||||
|
||||
const handleSign = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const { code, message, data } = await fetch.post<
|
||||
fetch.ResponseBody<SignReturn>
|
||||
>('/user/sign')
|
||||
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
setLastSign(new Date())
|
||||
setScore(data.score)
|
||||
setStorage(data.storage)
|
||||
} else {
|
||||
toast.warning(message)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="card card-primary card-outline">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{trans('user.used.title')}</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col-md-1"></div>
|
||||
<div className="col-md-6">
|
||||
<InfoBox
|
||||
color="teal"
|
||||
icon="gamepad"
|
||||
name={trans('user.used.players')}
|
||||
used={players.used}
|
||||
total={players.total}
|
||||
unit=""
|
||||
/>
|
||||
{storage.used > 1024 ? (
|
||||
<InfoBox
|
||||
color="maroon"
|
||||
icon="hdd"
|
||||
name={trans('user.used.storage')}
|
||||
used={~~(storage.used / 1024)}
|
||||
total={~~(storage.total / 1024)}
|
||||
unit="MB"
|
||||
/>
|
||||
) : (
|
||||
<InfoBox
|
||||
color="maroon"
|
||||
icon="hdd"
|
||||
name={trans('user.used.storage')}
|
||||
used={storage.used}
|
||||
total={storage.total}
|
||||
unit="KB"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-4 text-center">
|
||||
<p className={scoreStyle.title}>{trans('user.cur-score')}</p>
|
||||
<p
|
||||
className={scoreStyle.number}
|
||||
data-toggle="modal"
|
||||
data-target="#modal-score-instruction"
|
||||
>
|
||||
{~~score}
|
||||
</p>
|
||||
<p className={scoreStyle.notice}>{trans('user.score-notice')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<SignButton
|
||||
isLoading={loading}
|
||||
lastSign={lastSign}
|
||||
canSignAfterZero={canSignAfterZero}
|
||||
signGap={signGap}
|
||||
onClick={handleSign}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default hot(Dashboard)
|
||||
23
resources/assets/src/views/user/Dashboard/score.scss
Normal file
23
resources/assets/src/views/user/Dashboard/score.scss
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
@use 'sass:map';
|
||||
@use '../../../styles/breakpoints';
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
margin-top: 5px;
|
||||
|
||||
@include breakpoints.less-than('md') {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.number {
|
||||
font-family: 'Minecraft';
|
||||
font-size: 50px;
|
||||
margin-top: 20px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.notice {
|
||||
font-size: smaller;
|
||||
margin-top: 20px;
|
||||
}
|
||||
34
resources/assets/src/views/user/Dashboard/scoreUtils.ts
Normal file
34
resources/assets/src/views/user/Dashboard/scoreUtils.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { trans } from '../../../scripts/i18n'
|
||||
|
||||
const ONE_MINUTE = 60 * 1000
|
||||
const ONE_HOUR = 60 * ONE_MINUTE
|
||||
const ONE_DAY = 24 * ONE_HOUR
|
||||
|
||||
export function remainingTime(
|
||||
lastSign: Date,
|
||||
signGap: number,
|
||||
canSignAfterZero: boolean,
|
||||
): number {
|
||||
if (canSignAfterZero) {
|
||||
const today = new Date().setHours(0, 0, 0, 0)
|
||||
const tomorrow = today + ONE_DAY
|
||||
const rest = tomorrow - Date.now()
|
||||
|
||||
return lastSign.valueOf() < today ? 0 : rest
|
||||
}
|
||||
|
||||
return lastSign.valueOf() + signGap * ONE_HOUR - Date.now()
|
||||
}
|
||||
|
||||
export function remainingTimeText(remainingTime: number): string {
|
||||
const time = remainingTime / ONE_MINUTE
|
||||
return time < 60
|
||||
? trans('user.sign-remain-time', {
|
||||
time: ~~time,
|
||||
unit: trans('user.time-unit-min'),
|
||||
})
|
||||
: trans('user.sign-remain-time', {
|
||||
time: ~~(time / 60),
|
||||
unit: trans('user.time-unit-hour'),
|
||||
})
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
<template>
|
||||
<div class="container-fluid">
|
||||
<button
|
||||
type="primary"
|
||||
class="btn-create-app btn btn-primary"
|
||||
data-toggle="modal"
|
||||
data-target="#modal-create"
|
||||
>
|
||||
{{ $t('user.oauth.create') }}
|
||||
</button>
|
||||
<vue-good-table
|
||||
:rows="clients"
|
||||
:columns="columns"
|
||||
:search-options="tableOptions.search"
|
||||
:pagination-options="tableOptions.pagination"
|
||||
style-class="vgt-table striped"
|
||||
>
|
||||
<template #table-row="props">
|
||||
<span v-if="props.column.field === 'name'">
|
||||
{{ props.formattedRow[props.column.field] }}
|
||||
<a
|
||||
:title="$t('user.oauth.modifyName')"
|
||||
href="#"
|
||||
data-test="name"
|
||||
@click="modifyName(props.row)"
|
||||
>
|
||||
<i class="fas fa-edit btn-edit" />
|
||||
</a>
|
||||
</span>
|
||||
<span v-else-if="props.column.field === 'redirect'">
|
||||
{{ props.formattedRow[props.column.field] }}
|
||||
<a
|
||||
:title="$t('user.oauth.modifyUrl')"
|
||||
href="#"
|
||||
data-test="callback"
|
||||
@click="modifyCallback(props.row)"
|
||||
>
|
||||
<i class="fas fa-edit btn-edit" />
|
||||
</a>
|
||||
</span>
|
||||
<span v-else-if="props.column.field === 'operations'">
|
||||
<button class="btn btn-danger" data-test="remove" @click="remove(props.row)">
|
||||
{{ $t('report.delete') }}
|
||||
</button>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ props.formattedRow[props.column.field] }}
|
||||
</span>
|
||||
</template>
|
||||
</vue-good-table>
|
||||
|
||||
<modal id="modal-create" :title="$t('user.oauth.create')" @confirm="create">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td v-t="'user.oauth.name'" class="key" />
|
||||
<td class="value">
|
||||
<input v-model="name" class="form-control" type="text">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td v-t="'user.oauth.redirect'" class="key" />
|
||||
<td class="value">
|
||||
<input v-model="callback" class="form-control" type="text">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { VueGoodTable } from 'vue-good-table'
|
||||
import 'vue-good-table/dist/vue-good-table.min.css'
|
||||
import Modal from '../../components/Modal.vue'
|
||||
import tableOptions from '../../components/mixins/tableOptions'
|
||||
import emitMounted from '../../components/mixins/emitMounted'
|
||||
import { walkFetch, init } from '../../scripts/net'
|
||||
import { showModal, toast } from '../../scripts/notify'
|
||||
|
||||
export default {
|
||||
name: 'OAuthApps',
|
||||
components: {
|
||||
Modal,
|
||||
VueGoodTable,
|
||||
},
|
||||
mixins: [
|
||||
emitMounted,
|
||||
tableOptions,
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
callback: '',
|
||||
clients: [],
|
||||
columns: [
|
||||
{
|
||||
field: 'id', label: this.$t('user.oauth.id'), type: 'number',
|
||||
},
|
||||
{ field: 'name', label: this.$t('user.oauth.name') },
|
||||
{
|
||||
field: 'secret',
|
||||
label: this.$t('user.oauth.secret'),
|
||||
sortable: false,
|
||||
globalSearchDisabled: true,
|
||||
},
|
||||
{
|
||||
field: 'redirect',
|
||||
label: this.$t('user.oauth.redirect'),
|
||||
sortable: false,
|
||||
globalSearchDisabled: true,
|
||||
},
|
||||
{
|
||||
field: 'operations',
|
||||
label: this.$t('admin.operationsTitle'),
|
||||
sortable: false,
|
||||
globalSearchDisabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.clients = await this.$http.get('/oauth/clients')
|
||||
},
|
||||
async create() {
|
||||
const client = await this.$http.post('/oauth/clients', {
|
||||
name: this.name,
|
||||
redirect: this.callback,
|
||||
})
|
||||
if (client.id) {
|
||||
this.clients.unshift(client)
|
||||
} else {
|
||||
toast.error(client.message)
|
||||
}
|
||||
},
|
||||
async modifyName(client) {
|
||||
let name
|
||||
try {
|
||||
({ value: name } = await showModal({
|
||||
mode: 'prompt',
|
||||
title: this.$t('user.oauth.name'),
|
||||
input: client.name,
|
||||
}))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await this.modify(client, { name })
|
||||
},
|
||||
async modifyCallback(client) {
|
||||
let redirect
|
||||
try {
|
||||
({ value: redirect } = await showModal({
|
||||
mode: 'prompt',
|
||||
title: this.$t('user.oauth.redirect'),
|
||||
input: client.redirect,
|
||||
}))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await this.modify(client, { redirect })
|
||||
},
|
||||
async modify(client, modified) {
|
||||
const request = new Request(
|
||||
`/oauth/clients/${client.id}`,
|
||||
Object.assign({}, init, {
|
||||
body: JSON.stringify(Object.assign({ name: client.name, redirect: client.redirect }, modified)),
|
||||
method: 'PUT',
|
||||
}),
|
||||
)
|
||||
request.headers.set('Content-Type', 'application/json')
|
||||
const result = await walkFetch(request)
|
||||
if (result.id) {
|
||||
Object.assign(client, modified)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
},
|
||||
async remove(client) {
|
||||
try {
|
||||
await showModal({
|
||||
text: this.$t('user.oauth.confirmRemove'),
|
||||
okButtonType: 'danger',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const request = new Request(
|
||||
`/oauth/clients/${client.id}`,
|
||||
Object.assign({}, init, { method: 'DELETE' }),
|
||||
)
|
||||
await walkFetch(request)
|
||||
this.$delete(this.clients, this.clients.findIndex(({ id }) => id === client.id))
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus">
|
||||
.btn-create-app
|
||||
margin-bottom 5px
|
||||
margin-right 10px
|
||||
</style>
|
||||
65
resources/assets/src/views/user/OAuth/ModalCreate.tsx
Normal file
65
resources/assets/src/views/user/OAuth/ModalCreate.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import React, { useState } from 'react'
|
||||
import { t } from '../../../scripts/i18n'
|
||||
import Modal from '../../../components/Modal'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
onCreate(name: string, redirect: string): Promise<void>
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
const ModalCreate: React.FC<Props> = props => {
|
||||
const [name, setName] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(event.target.value)
|
||||
}
|
||||
|
||||
const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUrl(event.target.value)
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
props.onCreate(name, url)
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setName('')
|
||||
setUrl('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={props.show}
|
||||
onConfirm={handleComplete}
|
||||
onDismiss={handleDismiss}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-app-name">{t('user.oauth.name')}</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
className="form-control"
|
||||
id="new-app-name"
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-app-redirect">{t('user.oauth.redirect')}</label>
|
||||
<input
|
||||
value={url}
|
||||
onChange={handleUrlChange}
|
||||
className="form-control"
|
||||
id="new-app-redirect"
|
||||
type="url"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalCreate
|
||||
43
resources/assets/src/views/user/OAuth/Row.tsx
Normal file
43
resources/assets/src/views/user/OAuth/Row.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react'
|
||||
import { trans } from '../../../scripts/i18n'
|
||||
import ButtonEdit from '../../../components/ButtonEdit'
|
||||
import { App } from './types'
|
||||
|
||||
interface Props {
|
||||
app: App
|
||||
onEditName: React.MouseEventHandler<HTMLAnchorElement>
|
||||
onEditRedirect: React.MouseEventHandler<HTMLAnchorElement>
|
||||
onDelete: React.MouseEventHandler<HTMLButtonElement>
|
||||
}
|
||||
|
||||
const Row: React.FC<Props> = props => {
|
||||
const { app } = props
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>{app.id}</td>
|
||||
<td>
|
||||
<span>{app.name}</span>
|
||||
<ButtonEdit
|
||||
title={trans('user.oauth.modifyName')}
|
||||
onClick={props.onEditName}
|
||||
/>
|
||||
</td>
|
||||
<td>{app.secret}</td>
|
||||
<td>
|
||||
<span>{app.redirect}</span>
|
||||
<ButtonEdit
|
||||
title={trans('user.oauth.modifyUrl')}
|
||||
onClick={props.onEditRedirect}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-danger" onClick={props.onDelete}>
|
||||
{trans('report.delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export default Row
|
||||
162
resources/assets/src/views/user/OAuth/index.tsx
Normal file
162
resources/assets/src/views/user/OAuth/index.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
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, toast } from '@/scripts/notify'
|
||||
import Loading from '@/components/Loading'
|
||||
import Row from './Row'
|
||||
import ModalCreate from './ModalCreate'
|
||||
import { App } from './types'
|
||||
|
||||
type Exception = {
|
||||
message: string
|
||||
}
|
||||
|
||||
const OAuth: React.FC = () => {
|
||||
const [apps, setApps] = useState<App[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showModalCreate, setShowModalCreate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const getApps = async () => {
|
||||
setIsLoading(true)
|
||||
const allApps = await fetch.get<App[]>('/oauth/clients')
|
||||
setApps(allApps)
|
||||
setIsLoading(false)
|
||||
}
|
||||
getApps()
|
||||
}, [])
|
||||
|
||||
const handleShowModalCreate = () => setShowModalCreate(true)
|
||||
|
||||
const handleCloseModalCreate = () => setShowModalCreate(false)
|
||||
|
||||
const handleAdd = async (name: string, redirect: string) => {
|
||||
const result = await fetch.post<App | Exception>('/oauth/clients', {
|
||||
name,
|
||||
redirect,
|
||||
})
|
||||
if ('id' in result) {
|
||||
setApps(apps => [...apps, result])
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
const editName = async (app: App, index: number) => {
|
||||
let name: string
|
||||
try {
|
||||
const { value } = await showModal({
|
||||
mode: 'prompt',
|
||||
title: t('user.oauth.name'),
|
||||
input: app.name,
|
||||
})
|
||||
name = value
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await fetch.put<App | Exception>(
|
||||
`/oauth/clients/${app.id}`,
|
||||
{ ...app, name },
|
||||
)
|
||||
if ('id' in result) {
|
||||
setApps(apps => {
|
||||
apps[index] = { ...app, name }
|
||||
return apps.slice()
|
||||
})
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
const editRedirect = async (app: App, index: number) => {
|
||||
let redirect: string
|
||||
try {
|
||||
const { value } = await showModal({
|
||||
mode: 'prompt',
|
||||
title: t('user.oauth.redirect'),
|
||||
input: app.redirect,
|
||||
})
|
||||
redirect = value
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await fetch.put<App | Exception>(
|
||||
`/oauth/clients/${app.id}`,
|
||||
{ ...app, redirect },
|
||||
)
|
||||
if ('id' in result) {
|
||||
setApps(apps => {
|
||||
apps[index] = { ...app, redirect }
|
||||
return apps.slice()
|
||||
})
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (app: App) => {
|
||||
try {
|
||||
await showModal({
|
||||
text: t('user.oauth.confirmRemove'),
|
||||
okButtonType: 'danger',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
await fetch.del(`/oauth/clients/${app.id}`)
|
||||
setApps(apps => apps.filter(a => a.id !== app.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={handleShowModalCreate}>
|
||||
{t('user.oauth.create')}
|
||||
</button>
|
||||
<div className="card mt-2">
|
||||
<div className="card-body p-0">
|
||||
<table className="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('user.oauth.id')}</th>
|
||||
<th>{t('user.oauth.name')}</th>
|
||||
<th>{t('user.oauth.secret')}</th>
|
||||
<th>{t('user.oauth.redirect')}</th>
|
||||
<th>{t('admin.operationsTitle')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apps.length === 0 ? (
|
||||
<tr>
|
||||
<td className="text-center" colSpan={5}>
|
||||
{isLoading ? <Loading /> : 'Nothing here.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
apps.map((app, i) => (
|
||||
<Row
|
||||
key={app.id}
|
||||
app={app}
|
||||
onEditName={() => editName(app, i)}
|
||||
onEditRedirect={() => editRedirect(app, i)}
|
||||
onDelete={() => handleDelete(app)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<ModalCreate
|
||||
show={showModalCreate}
|
||||
onCreate={handleAdd}
|
||||
onClose={handleCloseModalCreate}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default hot(OAuth)
|
||||
6
resources/assets/src/views/user/OAuth/types.ts
Normal file
6
resources/assets/src/views/user/OAuth/types.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export type App = {
|
||||
id: number
|
||||
name: string
|
||||
secret: string
|
||||
redirect: string
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<portal selector="#players-list" :disabled="disablePortal">
|
||||
<div class="card card-primary">
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PID</th>
|
||||
<th v-t="'general.player.player-name'" />
|
||||
<th v-t="'user.player.operation'" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(player, index) in players"
|
||||
:key="player.pid"
|
||||
class="player"
|
||||
:class="{ 'player-selected': player.pid === selected }"
|
||||
@click="preview(player)"
|
||||
>
|
||||
<td class="pid">{{ player.pid }}</td>
|
||||
<td class="player-name">{{ player.name }}</td>
|
||||
<td>
|
||||
<button class="btn btn-default" @click="changeName(player)">
|
||||
{{ $t('user.player.edit-pname') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-warning"
|
||||
data-toggle="modal"
|
||||
data-target="#modal-clear-texture"
|
||||
>
|
||||
{{ $t('user.player.delete-texture') }}
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="deletePlayer(player, index)">
|
||||
{{ $t('user.player.delete-player') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button class="btn btn-primary" data-toggle="modal" data-target="#modal-add-player">
|
||||
<i class="fas fa-plus" aria-hidden="true" /> {{ $t('user.player.add-player') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</portal>
|
||||
|
||||
<portal selector="#previewer" :disabled="disablePortal">
|
||||
<previewer
|
||||
v-if="using3dPreviewer"
|
||||
:skin="skinUrl"
|
||||
:cape="capeUrl"
|
||||
:model="model"
|
||||
title="user.player.player-info"
|
||||
>
|
||||
<template #footer>
|
||||
<button class="btn btn-default" data-test="to2d" @click="togglePreviewer">
|
||||
{{ $t('user.switch2dPreview') }}
|
||||
</button>
|
||||
</template>
|
||||
</previewer>
|
||||
<div v-else class="card">
|
||||
<div class="card-header card-outline">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<h3 class="card-title" v-html="$t('user.player.player-info')" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="preview-2d">
|
||||
<p>
|
||||
{{ $t('general.skin') }}
|
||||
<a v-if="preview2d.skin" :href="`${baseUrl}/skinlib/show/${preview2d.skin}`">
|
||||
<img
|
||||
class="skin2d"
|
||||
:src="`${baseUrl}/preview/${preview2d.skin}?height=128`"
|
||||
>
|
||||
</a>
|
||||
<span v-else v-t="'user.player.texture-empty'" class="skin2d" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ $t('general.cape') }}
|
||||
<a v-if="preview2d.cape" :href="`${baseUrl}/skinlib/show/${preview2d.cape}`">
|
||||
<img
|
||||
class="skin2d"
|
||||
:src="`${baseUrl}/preview/${preview2d.cape}?height=128`"
|
||||
>
|
||||
</a>
|
||||
<span v-else v-t="'user.player.texture-empty'" class="skin2d" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button class="btn btn-default" @click="togglePreviewer">
|
||||
{{ $t('user.switch3dPreview') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</portal>
|
||||
|
||||
<portal selector="#modals" :disabled="disablePortal">
|
||||
<add-player-dialog @add="fetchPlayers" />
|
||||
|
||||
<modal
|
||||
id="modal-clear-texture"
|
||||
:title="$t('user.chooseClearTexture')"
|
||||
@confirm="clearTexture"
|
||||
>
|
||||
<label class="form-group">
|
||||
<input v-model="clear.skin" type="checkbox"> {{ $t('general.skin') }}
|
||||
</label>
|
||||
<br>
|
||||
<label class="form-group">
|
||||
<input v-model="clear.cape" type="checkbox"> {{ $t('general.cape') }}
|
||||
</label>
|
||||
</modal>
|
||||
</portal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from '../../components/Modal.vue'
|
||||
import Portal from '../../components/Portal'
|
||||
import AddPlayerDialog from '../../components/AddPlayerDialog.vue'
|
||||
import emitMounted from '../../components/mixins/emitMounted'
|
||||
import { showModal, toast } from '../../scripts/notify'
|
||||
import { truthy } from '../../scripts/validators'
|
||||
|
||||
export default {
|
||||
name: 'Players',
|
||||
components: {
|
||||
AddPlayerDialog,
|
||||
Modal,
|
||||
Portal,
|
||||
Previewer: () => import('../../components/Previewer.vue'),
|
||||
},
|
||||
mixins: [
|
||||
emitMounted,
|
||||
],
|
||||
props: {
|
||||
baseUrl: {
|
||||
type: String,
|
||||
default: blessing.base_url,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
players: [],
|
||||
selected: 0,
|
||||
using3dPreviewer: true,
|
||||
skinUrl: '',
|
||||
capeUrl: '',
|
||||
model: 'steve',
|
||||
preview2d: {
|
||||
skin: 0,
|
||||
cape: 0,
|
||||
},
|
||||
clear: {
|
||||
skin: false,
|
||||
cape: false,
|
||||
},
|
||||
disablePortal: process.env.NODE_ENV === 'test',
|
||||
}
|
||||
},
|
||||
async beforeMount() {
|
||||
await this.fetchPlayers()
|
||||
if (this.players.length === 1) {
|
||||
this.preview(this.players[0])
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchPlayers() {
|
||||
this.players = (await this.$http.get('/user/player/list')).data
|
||||
},
|
||||
togglePreviewer() {
|
||||
this.using3dPreviewer = !this.using3dPreviewer
|
||||
},
|
||||
async preview(player) {
|
||||
this.selected = player.pid
|
||||
|
||||
this.preview2d.skin = player.tid_skin
|
||||
this.preview2d.cape = player.tid_cape
|
||||
|
||||
if (player.tid_skin) {
|
||||
const { data: skin } = await this.$http.get(`/skinlib/info/${player.tid_skin}`)
|
||||
this.skinUrl = `${this.baseUrl}/textures/${skin.hash}`
|
||||
this.model = skin.type
|
||||
} else {
|
||||
this.skinUrl = ''
|
||||
this.model = 'steve'
|
||||
}
|
||||
if (player.tid_cape) {
|
||||
const { data: cape } = await this.$http.get(`/skinlib/info/${player.tid_cape}`)
|
||||
this.capeUrl = `${this.baseUrl}/textures/${cape.hash}`
|
||||
} else {
|
||||
this.capeUrl = ''
|
||||
}
|
||||
},
|
||||
async changeName(player) {
|
||||
let value
|
||||
try {
|
||||
({ value } = await showModal({
|
||||
mode: 'prompt',
|
||||
text: this.$t('user.changePlayerName'),
|
||||
input: player.name,
|
||||
validator: truthy(this.$t('user.emptyPlayerName')),
|
||||
}))
|
||||
} catch (_) {
|
||||
return
|
||||
}
|
||||
|
||||
const { code, message } = await this.$http.post(
|
||||
`/user/player/rename/${player.pid}`,
|
||||
{ name: value },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
player.name = value
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
},
|
||||
async clearTexture() {
|
||||
if (Object.values(this.clear).every(value => !value)) {
|
||||
toast.error(this.$t('user.noClearChoice'))
|
||||
return
|
||||
}
|
||||
|
||||
const { code, message } = await this.$http.post(
|
||||
`/user/player/texture/clear/${this.selected}`,
|
||||
this.clear,
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
const player = this.players.find(({ pid }) => pid === this.selected)
|
||||
Object.keys(this.clear)
|
||||
.filter(type => this.clear[type])
|
||||
.forEach(type => (player[`tid_${type}`] = 0))
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
},
|
||||
async deletePlayer(player, index) {
|
||||
try {
|
||||
await showModal({
|
||||
title: this.$t('user.deletePlayer'),
|
||||
text: this.$t('user.deletePlayerNotice'),
|
||||
okButtonType: 'danger',
|
||||
})
|
||||
} catch (_) {
|
||||
return
|
||||
}
|
||||
|
||||
const { code, message } = await this.$http.post(`/user/player/delete/${player.pid}`)
|
||||
if (code === 0) {
|
||||
this.$delete(this.players, index)
|
||||
toast.success(message)
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus">
|
||||
.player
|
||||
cursor pointer
|
||||
border-bottom 1px solid #f4f4f4
|
||||
|
||||
.pid, .player-name
|
||||
padding-top 13px
|
||||
|
||||
.player:last-child
|
||||
border-bottom none
|
||||
|
||||
.player-selected
|
||||
background-color #f5f5f5
|
||||
|
||||
.skin2d
|
||||
float right
|
||||
max-height 64px
|
||||
width 64px
|
||||
font-size 16px
|
||||
|
||||
#preview-2d > p
|
||||
height 64px
|
||||
line-height 64px
|
||||
</style>
|
||||
86
resources/assets/src/views/user/Players/ModalAddPlayer.tsx
Normal file
86
resources/assets/src/views/user/Players/ModalAddPlayer.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React, { useState } from 'react'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { toast } from '@/scripts/notify'
|
||||
import { Player } from '@/scripts/types'
|
||||
import Modal from '@/components/Modal'
|
||||
|
||||
type Extra = {
|
||||
score: number
|
||||
cost: number
|
||||
rule: string
|
||||
length: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
onAdd(player: Player): void
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
const ModalAddPlayer: React.FC<Props> = props => {
|
||||
const [name, setName] = useState('')
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(event.target.value)
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const { code, message, data: player } = await fetch.post<
|
||||
fetch.ResponseBody<Player>
|
||||
>('/user/player/add', { name })
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
props.onAdd(player)
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setName('')
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const { score, cost, rule, length } = blessing.extra as Extra
|
||||
const isScoreEnough = score >= cost
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={props.show}
|
||||
title={t('user.player.add-player')}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-player-name">
|
||||
{t('general.player.player-name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="new-player-name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="callout callout-info">
|
||||
<ul className="m-0 p-0 pl-3">
|
||||
<li>{rule}</li>
|
||||
<li>{length}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
className={`alert alert-${isScoreEnough ? 'success' : 'danger'}`}
|
||||
role="alert"
|
||||
>
|
||||
<i className={`icon fas fa-${isScoreEnough ? 'check' : 'times'}`}></i>
|
||||
<span className="ml-1">
|
||||
{t('user.cur-score')} {score}
|
||||
</span>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalAddPlayer
|
||||
62
resources/assets/src/views/user/Players/ModalReset.tsx
Normal file
62
resources/assets/src/views/user/Players/ModalReset.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React, { useState } from 'react'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import Modal from '@/components/Modal'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
onSubmit(skin: boolean, cape: boolean): Promise<void>
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
const ModalReset: React.FC<Props> = props => {
|
||||
const [skin, setSkin] = useState(false)
|
||||
const [cape, setCape] = useState(false)
|
||||
|
||||
const handleSkinChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSkin(event.target.checked)
|
||||
}
|
||||
|
||||
const handleCapeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCape(event.target.checked)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
props.onSubmit(skin, cape)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSkin(false)
|
||||
setCape(false)
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={props.show}
|
||||
title={t('user.chooseClearTexture')}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<label className="d-block">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mr-2"
|
||||
checked={skin}
|
||||
onChange={handleSkinChange}
|
||||
/>
|
||||
{t('general.skin')}
|
||||
</label>
|
||||
<label className="d-block">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mr-2"
|
||||
checked={cape}
|
||||
onChange={handleCapeChange}
|
||||
/>
|
||||
{t('general.cape')}
|
||||
</label>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalReset
|
||||
55
resources/assets/src/views/user/Players/Previewer.tsx
Normal file
55
resources/assets/src/views/user/Players/Previewer.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import ViewerSkeleton from '@/components/ViewerSkeleton'
|
||||
import Viewer2d from './Viewer2d'
|
||||
|
||||
const Viewer3d = React.lazy(() => import('@/components/Viewer'))
|
||||
|
||||
interface Props {
|
||||
skin: string
|
||||
cape: string
|
||||
isAlex: boolean
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
|
||||
const Previewer: React.FC<Props> = props => {
|
||||
const [is3d, setIs3d] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const mount = document.querySelector('#previewer')!
|
||||
mount.appendChild(container)
|
||||
|
||||
return () => {
|
||||
mount.removeChild(container)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const switchMode = () => setIs3d(is => !is)
|
||||
|
||||
const switcher = (
|
||||
<button className="btn btn-default" onClick={switchMode}>
|
||||
{is3d ? t('user.switch2dPreview') : t('user.switch3dPreview')}
|
||||
</button>
|
||||
)
|
||||
|
||||
const { skin, cape, isAlex } = props
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
is3d ? (
|
||||
<React.Suspense fallback={<ViewerSkeleton />}>
|
||||
<Viewer3d skin={skin} cape={cape} isAlex={isAlex}>
|
||||
{switcher}
|
||||
</Viewer3d>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<Viewer2d skin={skin} cape={cape}>
|
||||
{switcher}
|
||||
</Viewer2d>
|
||||
),
|
||||
container
|
||||
)
|
||||
}
|
||||
|
||||
export default Previewer
|
||||
7
resources/assets/src/views/user/Players/Row.scss
Normal file
7
resources/assets/src/views/user/Players/Row.scss
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: #efefef;
|
||||
}
|
||||
51
resources/assets/src/views/user/Players/Row.tsx
Normal file
51
resources/assets/src/views/user/Players/Row.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import ButtonEdit from '@/components/ButtonEdit'
|
||||
import { Player } from '@/scripts/types'
|
||||
import styles from './Row.scss'
|
||||
|
||||
interface Props {
|
||||
player: Player
|
||||
selected: boolean
|
||||
onClick: React.MouseEventHandler
|
||||
onEditName(player: Player): Promise<void>
|
||||
onReset(): void
|
||||
onDelete(player: Player): Promise<void>
|
||||
}
|
||||
|
||||
const Row: React.FC<Props> = props => {
|
||||
const { player } = props
|
||||
|
||||
const handleEdit = () => {
|
||||
props.onEditName(player)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
props.onDelete(player)
|
||||
}
|
||||
|
||||
const classes = [styles.row]
|
||||
if (props.selected) {
|
||||
classes.push(styles.selected)
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className={classes.join(' ')} onClick={props.onClick}>
|
||||
<td>{player.pid}</td>
|
||||
<td>
|
||||
<span>{player.name}</span>
|
||||
<ButtonEdit title={t('user.player.edit-pname')} onClick={handleEdit} />
|
||||
</td>
|
||||
<td className="d-flex">
|
||||
<button className="btn btn-warning" onClick={props.onReset}>
|
||||
{t('user.player.delete-texture')}
|
||||
</button>
|
||||
<button className="btn btn-danger ml-2" onClick={handleDelete}>
|
||||
{t('user.player.delete-player')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export default Row
|
||||
8
resources/assets/src/views/user/Players/Viewer2d.scss
Normal file
8
resources/assets/src/views/user/Players/Viewer2d.scss
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.texture {
|
||||
max-height: 64px;
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.line {
|
||||
width: 80%;
|
||||
}
|
||||
47
resources/assets/src/views/user/Players/Viewer2d.tsx
Normal file
47
resources/assets/src/views/user/Players/Viewer2d.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import styles from './Viewer2d.scss'
|
||||
|
||||
interface Props {
|
||||
skin: string
|
||||
cape: string
|
||||
}
|
||||
|
||||
const Viewer2d: React.FC<Props> = props => {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{t('general.texturePreview')}</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className={`d-flex justify-content-between mb-5 ${styles.line}`}>
|
||||
<span>{t('general.skin')}</span>
|
||||
{props.skin ? (
|
||||
<img
|
||||
src={props.skin}
|
||||
className={styles.texture}
|
||||
alt={t('general.skin')}
|
||||
/>
|
||||
) : (
|
||||
<span>{t('user.player.texture-empty')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`d-flex justify-content-between mt-5 ${styles.line}`}>
|
||||
<span>{t('general.cape')}</span>
|
||||
{props.cape ? (
|
||||
<img
|
||||
src={props.cape}
|
||||
className={styles.texture}
|
||||
alt={t('general.cape')}
|
||||
/>
|
||||
) : (
|
||||
<span>{t('user.player.texture-empty')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Viewer2d
|
||||
221
resources/assets/src/views/user/Players/index.tsx
Normal file
221
resources/assets/src/views/user/Players/index.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
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, toast } from '@/scripts/notify'
|
||||
import useTexture from '@/scripts/hooks/useTexture'
|
||||
import { Player } from '@/scripts/types'
|
||||
import Loading from '@/components/Loading'
|
||||
import Row from './Row'
|
||||
import Previewer from './Previewer'
|
||||
import ModalAddPlayer from './ModalAddPlayer'
|
||||
import ModalReset from './ModalReset'
|
||||
|
||||
const Players: React.FC = () => {
|
||||
const [players, setPlayers] = useState<Player[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [selected, setSelected] = useState(0)
|
||||
const [skin, setSkin] = useTexture()
|
||||
const [cape, setCape] = useTexture()
|
||||
const [search, setSearch] = useState('')
|
||||
const [showModalAddPlayer, setShowModalAddPlayer] = useState(false)
|
||||
const [showModalReset, setShowModalReset] = useState(false)
|
||||
|
||||
const selectPlayer = (player: Player) => {
|
||||
setSelected(player.pid)
|
||||
setSkin(player.tid_skin)
|
||||
setCape(player.tid_cape)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const getPlayers = async () => {
|
||||
setIsLoading(true)
|
||||
const { data } = await fetch.get<fetch.ResponseBody<Player[]>>(
|
||||
'/user/player/list',
|
||||
)
|
||||
setPlayers(data)
|
||||
if (data.length === 1) {
|
||||
selectPlayer(data[0])
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
getPlayers()
|
||||
}, [])
|
||||
|
||||
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(event.target.value)
|
||||
}
|
||||
|
||||
const handleAdd = (player: Player) => {
|
||||
setPlayers(players => [...players, player])
|
||||
}
|
||||
|
||||
const editName = async (player: Player, index: number) => {
|
||||
let name: string
|
||||
try {
|
||||
const { value } = await showModal({
|
||||
mode: 'prompt',
|
||||
text: t('user.changePlayerName'),
|
||||
input: player.name,
|
||||
validator: (value: string) => {
|
||||
if (!value) {
|
||||
return t('user.emptyPlayerName')
|
||||
}
|
||||
},
|
||||
})
|
||||
name = value
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
`/user/player/rename/${player.pid}`,
|
||||
{ name },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
setPlayers(players => {
|
||||
players[index] = { ...player, name }
|
||||
return players.slice()
|
||||
})
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const resetTexture = async (skin: boolean, cape: boolean) => {
|
||||
if (!skin && !cape) {
|
||||
toast.warning(t('user.noClearChoice'))
|
||||
return
|
||||
}
|
||||
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
`/user/player/texture/clear/${selected}`,
|
||||
{ type: [skin && 'skin', cape && 'cape'].filter(Boolean) },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
if (skin) {
|
||||
setSkin(0)
|
||||
}
|
||||
if (cape) {
|
||||
setCape(0)
|
||||
}
|
||||
setPlayers(players => {
|
||||
const index = players.findIndex(player => player.pid === selected)
|
||||
const player = Object.assign({}, players[index])
|
||||
if (skin) {
|
||||
player.tid_skin = 0
|
||||
}
|
||||
if (cape) {
|
||||
player.tid_cape = 0
|
||||
}
|
||||
players[index] = player
|
||||
return players.slice()
|
||||
})
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const deletePlayer = async (player: Player) => {
|
||||
try {
|
||||
await showModal({
|
||||
title: t('user.deletePlayer'),
|
||||
text: t('user.deletePlayerNotice'),
|
||||
okButtonType: 'danger',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
`/user/player/delete/${player.pid}`,
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
const { pid } = player
|
||||
setPlayers(players => players.filter(player => player.pid !== pid))
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const openModalAddPlayer = () => setShowModalAddPlayer(true)
|
||||
const closeModalAddPlayer = () => setShowModalAddPlayer(false)
|
||||
|
||||
const openModalReset = () => setShowModalReset(true)
|
||||
const closeModalReset = () => setShowModalReset(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('user.typeToSearch')}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
<div className="card-body p-0 table-responsive">
|
||||
<table className="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '12%' }}>PID</th>
|
||||
<th>{t('general.player.player-name')}</th>
|
||||
<th style={{ width: '50%' }}>{t('user.player.operation')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{players.length === 0 ? (
|
||||
<tr>
|
||||
<td className="text-center" colSpan={3}>
|
||||
{isLoading ? <Loading /> : 'Nothing here.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
players
|
||||
.filter(({ name }) => name.includes(search))
|
||||
.map((player, i) => (
|
||||
<Row
|
||||
key={player.pid}
|
||||
player={player}
|
||||
selected={selected === player.pid}
|
||||
onClick={() => selectPlayer(player)}
|
||||
onEditName={() => editName(player, i)}
|
||||
onReset={openModalReset}
|
||||
onDelete={deletePlayer}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button className="btn btn-primary" onClick={openModalAddPlayer}>
|
||||
<i className="fas fa-plus mr-1"></i>
|
||||
<span>{t('user.player.add-player')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Previewer
|
||||
skin={skin.url}
|
||||
cape={cape.url}
|
||||
isAlex={skin.type === 'alex'}
|
||||
/>
|
||||
<ModalAddPlayer
|
||||
show={showModalAddPlayer}
|
||||
onAdd={handleAdd}
|
||||
onClose={closeModalAddPlayer}
|
||||
/>
|
||||
<ModalReset
|
||||
show={showModalReset}
|
||||
onSubmit={resetTexture}
|
||||
onClose={closeModalReset}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default hot(Players)
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
<template>
|
||||
<div class="container-fluid">
|
||||
<vue-good-table
|
||||
:rows="reports"
|
||||
:columns="columns"
|
||||
:search-options="tableOptions.search"
|
||||
:pagination-options="tableOptions.pagination"
|
||||
style-class="vgt-table striped"
|
||||
>
|
||||
<template #table-row="props">
|
||||
<span v-if="props.column.field === 'tid'">
|
||||
{{ props.formattedRow[props.column.field] }}
|
||||
<a :href="`${baseUrl}/skinlib/show/${props.row.tid}`">
|
||||
<i class="fa fa-share" />
|
||||
</a>
|
||||
</span>
|
||||
<span v-else-if="props.column.field === 'status'">
|
||||
{{ $t(`report.status.${props.row.status}`) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ props.formattedRow[props.column.field] }}
|
||||
</span>
|
||||
</template>
|
||||
</vue-good-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { VueGoodTable } from 'vue-good-table'
|
||||
import 'vue-good-table/dist/vue-good-table.min.css'
|
||||
import tableOptions from '../../components/mixins/tableOptions'
|
||||
import emitMounted from '../../components/mixins/emitMounted'
|
||||
|
||||
export default {
|
||||
name: 'MyReports',
|
||||
components: {
|
||||
VueGoodTable,
|
||||
},
|
||||
mixins: [
|
||||
emitMounted,
|
||||
tableOptions,
|
||||
],
|
||||
props: {
|
||||
baseUrl: {
|
||||
type: String,
|
||||
default: blessing.base_url,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
reports: [],
|
||||
columns: [
|
||||
{
|
||||
field: 'id', label: 'ID', type: 'number',
|
||||
},
|
||||
{
|
||||
field: 'tid', label: this.$t('report.tid'), type: 'number',
|
||||
},
|
||||
{
|
||||
field: 'reason',
|
||||
label: this.$t('report.reason'),
|
||||
sortable: false,
|
||||
},
|
||||
{ field: 'status', label: this.$t('report.status-title') },
|
||||
{
|
||||
field: 'report_at',
|
||||
label: this.$t('report.time'),
|
||||
globalSearchDisabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.reports = await this.$http.get('/user/reports/list')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
44
resources/assets/src/views/widgets/EmailVerification.tsx
Normal file
44
resources/assets/src/views/widgets/EmailVerification.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React, { useState } from 'react'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { toast } from '@/scripts/notify'
|
||||
|
||||
const EmailVerification: React.FC = () => {
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
|
||||
const send = async () => {
|
||||
setIsSending(true)
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
'/user/email-verification',
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
setIsSending(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="callout callout-info">
|
||||
<h4>
|
||||
<i className="fas fa-envelope"></i> {t('user.verification.title')}
|
||||
</h4>
|
||||
<p>
|
||||
{t('user.verification.message')}
|
||||
{isSending ? (
|
||||
<>
|
||||
<i className="fas fa-spin fa-spinner mr-1"></i>
|
||||
{t('user.verification.sending')}
|
||||
</>
|
||||
) : (
|
||||
<a href="#" onClick={send}>
|
||||
{t('user.verification.resend')}
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailVerification
|
||||
84
resources/assets/src/views/widgets/NotificationsList.tsx
Normal file
84
resources/assets/src/views/widgets/NotificationsList.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { showModal } from '@/scripts/notify'
|
||||
|
||||
export type Notification = {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const NotificationsList: React.FC = () => {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [noUnreadText, setNoUnreadText] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const dataset = document.querySelector<HTMLLIElement>(
|
||||
'[data-notifications]',
|
||||
)?.dataset
|
||||
if (dataset) {
|
||||
const notifications: Notification[] = JSON.parse(dataset.notifications!)
|
||||
setNotifications(notifications)
|
||||
setNoUnreadText(dataset.t!)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const read = async (id: string) => {
|
||||
const { title, content, time } = await fetch.get<{
|
||||
title: string
|
||||
content: string
|
||||
time: string
|
||||
}>(`/user/notifications/${id}`)
|
||||
|
||||
showModal({
|
||||
mode: 'alert',
|
||||
title,
|
||||
children: (
|
||||
<>
|
||||
<div dangerouslySetInnerHTML={{ __html: content }}></div>
|
||||
<br />
|
||||
<small>{time}</small>
|
||||
</>
|
||||
),
|
||||
})
|
||||
setNotifications(notifications =>
|
||||
notifications.filter(notification => notification.id !== id),
|
||||
)
|
||||
}
|
||||
|
||||
const hasUnread = notifications.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<a className="nav-link" data-toggle="dropdown" href="#">
|
||||
<i className="far fa-bell"></i>
|
||||
{hasUnread && (
|
||||
<span className="badge badge-warning navbar-badge">
|
||||
{notifications.length}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
<div className="dropdown-menu dropdown-menu-lg dropdown-menu-right">
|
||||
{hasUnread ? (
|
||||
notifications.map(notification => (
|
||||
<>
|
||||
<a
|
||||
href="#"
|
||||
className="dropdown-item"
|
||||
key={notification.id}
|
||||
onClick={() => read(notification.id)}
|
||||
>
|
||||
<i className="far fa-circle text-info mr-2"></i>
|
||||
{notification.title}
|
||||
</a>
|
||||
<div className="dropdown-divider"></div>
|
||||
</>
|
||||
))
|
||||
) : (
|
||||
<p className="text-center text-muted pt-2 pb-2">{noUnreadText}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationsList
|
||||
17
resources/assets/src/webpack.d.ts
vendored
Normal file
17
resources/assets/src/webpack.d.ts
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
declare module '*.vue' {
|
||||
import Vue from 'vue'
|
||||
|
||||
export default Vue
|
||||
}
|
||||
|
||||
declare module '*.styl' {
|
||||
export default {} as Record<string, string>
|
||||
}
|
||||
|
||||
declare module '*.scss' {
|
||||
export default {} as Record<string, string>
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
export default ''
|
||||
}
|
||||
3
resources/assets/tests/__mocks__/lodash.debounce.ts
Normal file
3
resources/assets/tests/__mocks__/lodash.debounce.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function debounce(fn: Function, _?: number) {
|
||||
return fn
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import Vue from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '../utils'
|
||||
import { toast } from '@/scripts/notify'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import AddPlayerDialog from '@/components/AddPlayerDialog.vue'
|
||||
|
||||
jest.mock('@/scripts/notify')
|
||||
|
||||
window.blessing.extra = {
|
||||
rule: 'rule',
|
||||
length: 'length',
|
||||
}
|
||||
|
||||
test('add player', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValueOnce([])
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ code: 1, message: 'fail' })
|
||||
.mockResolvedValue({ code: 0, message: 'ok' })
|
||||
const wrapper = mount(AddPlayerDialog)
|
||||
const modal = wrapper.find(Modal)
|
||||
wrapper.find('input[type="text"]').setValue('the-new')
|
||||
|
||||
modal.vm.$emit('confirm')
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith(
|
||||
'/user/player/add',
|
||||
{ name: 'the-new' },
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('the-new')
|
||||
expect(toast.error).toBeCalledWith('fail')
|
||||
|
||||
modal.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted().add).toBeDefined()
|
||||
expect(toast.success).toBeCalledWith('ok')
|
||||
})
|
||||
|
|
@ -2,6 +2,7 @@ import 'bootstrap'
|
|||
import Vue from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '../utils'
|
||||
import { trans } from '@/scripts/i18n'
|
||||
import { toast } from '@/scripts/notify'
|
||||
import ApplyToPlayerDialog from '@/components/ApplyToPlayerDialog.vue'
|
||||
|
||||
|
|
@ -16,7 +17,7 @@ test('submit applying texture', async () => {
|
|||
const button = wrapper.find('.btn-outline-info')
|
||||
|
||||
button.trigger('click')
|
||||
expect(toast.info).toBeCalledWith('user.emptySelectedTexture')
|
||||
expect(toast.info).toBeCalledWith(trans('user.emptySelectedTexture'))
|
||||
|
||||
wrapper.setProps({ skin: 1 })
|
||||
button.trigger('click')
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
import Vue from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '../utils'
|
||||
import { showModal } from '@/scripts/notify'
|
||||
import ClosetItem from '@/components/ClosetItem.vue'
|
||||
|
||||
jest.mock('@/scripts/notify')
|
||||
|
||||
function factory(opt = {}) {
|
||||
return {
|
||||
tid: 1,
|
||||
name: 'texture',
|
||||
type: 'steve',
|
||||
...opt,
|
||||
}
|
||||
}
|
||||
|
||||
test('computed values', () => {
|
||||
const wrapper = mount(ClosetItem, { propsData: factory() })
|
||||
expect(wrapper.find('img').attributes('src')).toBe('/preview/1?height=150')
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('.dropdown-item')
|
||||
.at(2)
|
||||
.attributes('href'),
|
||||
).toBe('/skinlib/show/1')
|
||||
})
|
||||
|
||||
test('selected item', () => {
|
||||
const wrapper = mount(ClosetItem, { propsData: factory({ selected: true }) })
|
||||
expect(wrapper.find('.card').classes('shadow')).toBeTrue()
|
||||
})
|
||||
|
||||
test('click item body', () => {
|
||||
const wrapper = mount(ClosetItem, { propsData: factory() })
|
||||
|
||||
wrapper.find('.card').trigger('click')
|
||||
expect(wrapper.emitted().select).toBeUndefined()
|
||||
|
||||
wrapper.find('.card-body').trigger('click')
|
||||
expect(wrapper.emitted().select).toBeTruthy()
|
||||
})
|
||||
|
||||
test('rename texture', async () => {
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ code: 0 })
|
||||
.mockResolvedValueOnce({ code: 1 })
|
||||
showModal
|
||||
.mockRejectedValueOnce(null)
|
||||
.mockResolvedValue({ value: 'new-name' })
|
||||
const wrapper = mount(ClosetItem, { propsData: factory() })
|
||||
const button = wrapper.findAll('.dropdown-item').at(0)
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.post).not.toBeCalled()
|
||||
|
||||
// Warning message
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-test="name"]').text()).toBe('new-name (steve)')
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith(
|
||||
'/user/closet/rename/1',
|
||||
{ name: 'new-name' },
|
||||
)
|
||||
})
|
||||
|
||||
test('remove texture', async () => {
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ code: 0 })
|
||||
.mockResolvedValueOnce({ code: 1 })
|
||||
showModal
|
||||
.mockRejectedValueOnce(null)
|
||||
.mockResolvedValue({ value: '' })
|
||||
|
||||
const wrapper = mount(ClosetItem, { propsData: factory() })
|
||||
const button = wrapper.findAll('.dropdown-item').at(1)
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.post).not.toBeCalled()
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted()['item-removed']).toBeTruthy()
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith('/user/closet/remove/1')
|
||||
})
|
||||
|
||||
test('set as avatar', async () => {
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ code: 0 })
|
||||
.mockResolvedValueOnce({ code: 1 })
|
||||
showModal
|
||||
.mockRejectedValueOnce(null)
|
||||
.mockResolvedValue({ value: '' })
|
||||
|
||||
const wrapper = mount(ClosetItem, { propsData: factory() })
|
||||
const button = wrapper.findAll('.dropdown-item').at(3)
|
||||
document.body.innerHTML += '<img alt="User Image" src="a">'
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.post).not.toBeCalled()
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith('/user/profile/avatar', { tid: 1 })
|
||||
expect(document.querySelector('img')!.src).toMatch(/\d+$/)
|
||||
})
|
||||
|
||||
test('no avatar option if texture is cape', () => {
|
||||
const wrapper = mount(ClosetItem, { propsData: factory({ type: 'cape' }) })
|
||||
expect(wrapper.findAll('.dropdown-item')).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('truncate too long texture name', () => {
|
||||
const wrapper = mount(ClosetItem, {
|
||||
propsData: factory({ name: 'very-very-long-texture-name' }),
|
||||
})
|
||||
expect(wrapper.text()).toContain('very-very-long-...')
|
||||
})
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import Vue from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '../utils'
|
||||
import { toast } from '@/scripts/notify'
|
||||
import EmailVerification from '@/components/EmailVerification.vue'
|
||||
|
||||
jest.mock('@/scripts/notify')
|
||||
|
||||
test('message box should not be render if verified', () => {
|
||||
window.blessing.extra = { unverified: false }
|
||||
const wrapper = mount(EmailVerification)
|
||||
expect(wrapper.isEmpty()).toBeTrue()
|
||||
})
|
||||
|
||||
test('resend email', async () => {
|
||||
window.blessing.extra = { unverified: true }
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ code: 1, message: '1' })
|
||||
.mockResolvedValueOnce({ code: 0, message: '0' })
|
||||
const wrapper = mount(EmailVerification)
|
||||
const button = wrapper.find('a')
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(toast.error).toBeCalledWith('1')
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(toast.success).toBeCalledWith('0')
|
||||
})
|
||||
289
resources/assets/tests/components/Modal.test.tsx
Normal file
289
resources/assets/tests/components/Modal.test.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import React from 'react'
|
||||
import { render, fireEvent, act } from '@testing-library/react'
|
||||
import { trans } from '@/scripts/i18n'
|
||||
import $ from 'jquery'
|
||||
import Modal from '@/components/Modal'
|
||||
|
||||
test('hidden by default', () => {
|
||||
const { queryByRole } = render(<Modal />)
|
||||
expect(queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
test('receive id', () => {
|
||||
const { getByRole } = render(<Modal id="kumiko" show />)
|
||||
expect(getByRole('dialog')).toHaveAttribute('id', 'kumiko')
|
||||
})
|
||||
|
||||
test('centered dialog', () => {
|
||||
const { getByRole } = render(<Modal center show />)
|
||||
expect(getByRole('document')).toHaveClass('modal-dialog-centered')
|
||||
})
|
||||
|
||||
test('background color', () => {
|
||||
const { container } = render(<Modal type="primary" show />)
|
||||
expect(container.querySelector('.modal-content')).toHaveClass('bg-primary')
|
||||
})
|
||||
|
||||
test('jQuery events', () => {
|
||||
const { getByText } = render(<Modal mode="confirm" show />)
|
||||
act(() => {
|
||||
jest.runAllTimers()
|
||||
$('.modal').trigger('hidden.bs.modal')
|
||||
})
|
||||
|
||||
fireEvent.click(getByText(trans('general.cancel')))
|
||||
act(() => {
|
||||
jest.runAllTimers()
|
||||
$('.modal').trigger('hidden.bs.modal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal header', () => {
|
||||
it('modal title', () => {
|
||||
const { queryByText } = render(<Modal title="Tips" show />)
|
||||
expect(queryByText('Tips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hide modal header', () => {
|
||||
const { queryByText } = render(
|
||||
<Modal title="Tips" showHeader={false} show />,
|
||||
)
|
||||
expect(queryByText('Tips')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal body', () => {
|
||||
it('custom children', () => {
|
||||
const { queryByText } = render(<Modal show>body</Modal>)
|
||||
expect(queryByText('body')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('text with linebreaks', () => {
|
||||
const { queryByText } = render(<Modal text={'L1\nL2'} show />)
|
||||
expect(queryByText('L1')).toBeInTheDocument()
|
||||
expect(queryByText('L2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dangerous HTML', () => {
|
||||
const { getByText } = render(
|
||||
<Modal dangerousHTML="<h1 class='h1'>ab</h1>" show />,
|
||||
)
|
||||
expect(getByText('ab')).toHaveClass('h1')
|
||||
})
|
||||
|
||||
describe('input control', () => {
|
||||
it('set default value', () => {
|
||||
const { queryByDisplayValue } = render(
|
||||
<Modal mode="prompt" input="val" show />,
|
||||
)
|
||||
expect(queryByDisplayValue('val')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('placeholder', () => {
|
||||
const { queryByPlaceholderText } = render(
|
||||
<Modal mode="prompt" placeholder="hint" show />,
|
||||
)
|
||||
expect(queryByPlaceholderText('hint')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('input control type', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<Modal
|
||||
mode="prompt"
|
||||
placeholder="password"
|
||||
inputType="password"
|
||||
show
|
||||
/>,
|
||||
)
|
||||
expect(getByPlaceholderText('password')).toHaveAttribute(
|
||||
'type',
|
||||
'password',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal footer', () => {
|
||||
it('custom footer content', () => {
|
||||
const { queryByText } = render(<Modal footer={<div>footer</div>} show />)
|
||||
expect(queryByText('footer')).toBeInTheDocument()
|
||||
expect(queryByText(trans('general.confirm'))).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('flex footer', () => {
|
||||
const { getByText } = render(<Modal footer="footer" flexFooter show />)
|
||||
expect(getByText('footer')).toHaveClass('d-flex', 'justify-content-between')
|
||||
})
|
||||
|
||||
it('custom ok button', () => {
|
||||
const { getByText } = render(
|
||||
<Modal okButtonType="primary" okButtonText="kumiko" show />,
|
||||
)
|
||||
expect(getByText('kumiko')).toHaveClass('btn-primary')
|
||||
})
|
||||
|
||||
it('custom cancel button', () => {
|
||||
const { getByText } = render(
|
||||
<Modal cancelButtonType="success" cancelButtonText="reina" show />,
|
||||
)
|
||||
expect(getByText('reina')).toHaveClass('btn-success')
|
||||
})
|
||||
})
|
||||
|
||||
describe('"alert" mode', () => {
|
||||
it('buttons', () => {
|
||||
const resolve = jest.fn()
|
||||
const { getByText, queryByText } = render(
|
||||
<Modal mode="alert" onConfirm={resolve} show />,
|
||||
)
|
||||
fireEvent.click(getByText(trans('general.confirm')))
|
||||
expect(resolve).toBeCalledWith({ value: '' })
|
||||
expect(queryByText(trans('general.cancel'))).toBeNull()
|
||||
})
|
||||
|
||||
it('confirm callback is optional', () => {
|
||||
const { getByText } = render(<Modal mode="alert" show />)
|
||||
fireEvent.click(getByText(trans('general.confirm')))
|
||||
})
|
||||
})
|
||||
|
||||
describe('"confirm" mode', () => {
|
||||
it('default mode is "confirm"', () => {
|
||||
const { queryByText } = render(<Modal show />)
|
||||
expect(queryByText(trans('general.confirm'))).toBeInTheDocument()
|
||||
expect(queryByText(trans('general.cancel'))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"confirm" button', () => {
|
||||
const resolve = jest.fn()
|
||||
const reject = jest.fn()
|
||||
const { getByText } = render(
|
||||
<Modal mode="prompt" onConfirm={resolve} onDismiss={reject} show />,
|
||||
)
|
||||
fireEvent.click(getByText(trans('general.confirm')))
|
||||
expect(resolve).toBeCalledWith({ value: '' })
|
||||
expect(reject).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('"cancel" button', () => {
|
||||
const resolve = jest.fn()
|
||||
const reject = jest.fn()
|
||||
const { getByText } = render(
|
||||
<Modal mode="prompt" onConfirm={resolve} onDismiss={reject} show />,
|
||||
)
|
||||
fireEvent.click(getByText(trans('general.cancel')))
|
||||
expect(resolve).not.toBeCalled()
|
||||
expect(reject).toBeCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('"prompt" mode', () => {
|
||||
it('retrieve input value', () => {
|
||||
const resolve = jest.fn()
|
||||
const reject = jest.fn()
|
||||
const { getByPlaceholderText, getByText } = render(
|
||||
<Modal
|
||||
mode="prompt"
|
||||
placeholder="hint"
|
||||
onConfirm={resolve}
|
||||
onDismiss={reject}
|
||||
show
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(getByPlaceholderText('hint'), {
|
||||
target: { value: 'my' },
|
||||
})
|
||||
fireEvent.click(getByText(trans('general.confirm')))
|
||||
expect(resolve).toBeCalledWith({ value: 'my' })
|
||||
expect(reject).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('cancel dialog', () => {
|
||||
const resolve = jest.fn()
|
||||
const reject = jest.fn()
|
||||
const { getByText } = render(
|
||||
<Modal mode="prompt" onConfirm={resolve} onDismiss={reject} show />,
|
||||
)
|
||||
fireEvent.click(getByText(trans('general.cancel')))
|
||||
expect(resolve).not.toBeCalled()
|
||||
expect(reject).toBeCalled()
|
||||
})
|
||||
|
||||
it('radio inputs', () => {
|
||||
const choices = [
|
||||
{ text: 'A', value: 'a' },
|
||||
{ text: 'B', value: 'b' },
|
||||
]
|
||||
const resolve = jest.fn()
|
||||
const { getByText, getByLabelText } = render(
|
||||
<Modal
|
||||
mode="prompt"
|
||||
inputType="radios"
|
||||
choices={choices}
|
||||
onConfirm={resolve}
|
||||
show
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(getByLabelText('B'))
|
||||
fireEvent.click(getByText(trans('general.confirm')))
|
||||
expect(resolve).toBeCalledWith({ value: 'b' })
|
||||
})
|
||||
|
||||
it('validate input', () => {
|
||||
const resolve = jest.fn()
|
||||
const reject = jest.fn()
|
||||
const validator = jest.fn().mockReturnValue(true)
|
||||
const { getByText } = render(
|
||||
<Modal
|
||||
mode="prompt"
|
||||
input="val"
|
||||
validator={validator}
|
||||
onConfirm={resolve}
|
||||
onDismiss={reject}
|
||||
show
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(getByText(trans('general.confirm')))
|
||||
expect(resolve).toBeCalledWith({ value: 'val' })
|
||||
expect(reject).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('report validator message', () => {
|
||||
const message = 'Invalid input.'
|
||||
const resolve = jest.fn()
|
||||
const reject = jest.fn()
|
||||
const validator = jest.fn().mockReturnValue(message)
|
||||
const { getByText, queryByText } = render(
|
||||
<Modal
|
||||
mode="prompt"
|
||||
input="val"
|
||||
validator={validator}
|
||||
onConfirm={resolve}
|
||||
onDismiss={reject}
|
||||
show
|
||||
/>,
|
||||
)
|
||||
expect(queryByText(message)).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(getByText(trans('general.confirm')))
|
||||
expect(queryByText(message)).toBeInTheDocument()
|
||||
expect(resolve).not.toBeCalled()
|
||||
expect(reject).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('"onClose" event', () => {
|
||||
it('button confirm', () => {
|
||||
const mock = jest.fn()
|
||||
const { getByText } = render(<Modal show mode="confirm" onClose={mock} />)
|
||||
fireEvent.click(getByText(trans('general.confirm')))
|
||||
expect(mock).toBeCalled()
|
||||
})
|
||||
|
||||
it('button cancel', () => {
|
||||
const mock = jest.fn()
|
||||
const { getByText } = render(<Modal show mode="confirm" onClose={mock} />)
|
||||
fireEvent.click(getByText(trans('general.cancel')))
|
||||
expect(mock).toBeCalled()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user