Merge branch 'react' into dev

This commit is contained in:
Pig Fang 2020-02-16 10:07:23 +08:00
commit 76bf342681
155 changed files with 6585 additions and 3711 deletions

View File

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

View File

@ -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'),
]);
}

View File

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

View File

@ -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');
}
}

View File

@ -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'),
]);
}

View File

@ -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,
]);
}

View File

@ -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' => [],
]);
});

View File

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

View File

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

View 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

View File

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

View File

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

View 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

View 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

View 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

View 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

View 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

View 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>&times;</span>
</button>
</div>
) : null
export default ModalHeader

View 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

View 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

View 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

View 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);
}

View 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}
>
&times;
</button>
</div>
</div>
)
}
Toast.displayName = 'Toast'
export default Toast

View 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;
}
}

View 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

View 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

View File

@ -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!)
}
},
},

View File

@ -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!)
}
},
},

View File

@ -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!)
}
},
},

View File

@ -0,0 +1,5 @@
@font-face {
font-family: Minecraft;
src: url('./minecraft.woff2') format('woff2'),
url('./minecraft.woff') format('woff');
}

View File

@ -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),
})
}
}
}

View 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),
})
}
}
}

View File

@ -5,6 +5,7 @@ import './i18n'
import './net'
import './event'
import './notification'
import './emailVerification'
import './logout'
window.addEventListener('load', () => {

View 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)
}

View File

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

View 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]
}

View 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]
}

View File

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

View File

@ -1,4 +0,0 @@
const chooser = document.querySelector<HTMLInputElement>('#language-chooser')
chooser?.addEventListener('change', () => {
window.location.href = `?lang=${chooser.value}`
})

View File

@ -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()
})
})
}

View 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,
)
})
}

View File

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

View File

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

View 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)
}

View File

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

View File

@ -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">&nbsp;</div>
<div className="card-body"></div>
<div className="card-footer">&nbsp;</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">&nbsp;</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',

View File

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

View 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)
}
}

View 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 }
}

View File

@ -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('&')
}

View File

@ -1,5 +1,5 @@
export function truthy(message: string) {
return (value?: unknown): string | void => {
return (value?: unknown): string | undefined => {
if (!value) {
return message
}

View File

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

View 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;
}
}

View File

@ -0,0 +1,5 @@
@mixin truncate-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

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

View File

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

View File

@ -0,0 +1,15 @@
body {
height: 97vh;
}
.hero {
align-items: center;
}
h1 {
font-family: Minecraft;
}
a:hover {
text-decoration: none;
}

View File

@ -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)">&nbsp;
<strong>{{ plugin.title }}</strong>&nbsp;
<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>

View File

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

View File

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

View 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)

View 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 }
}

View File

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

View 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)

View File

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

View File

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

View 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

View 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

View 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

View 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)

View File

@ -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" /> &nbsp;{{ $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" /> &nbsp;
{{ 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>

View 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)

View 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" /> &nbsp;
{canSign ? trans('user.sign') : remainingTimeText}
</button>
)
}
export default React.memo(SignButton)

View 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)

View 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;
}

View 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'),
})
}

View File

@ -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] }}&nbsp;
<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] }}&nbsp;
<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>

View 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

View 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

View 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)

View File

@ -0,0 +1,6 @@
export type App = {
id: number
name: string
secret: string
redirect: string
}

View File

@ -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" />&nbsp;{{ $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>

View 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

View 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

View 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

View File

@ -0,0 +1,7 @@
.row {
cursor: pointer;
}
.selected {
background-color: #efefef;
}

View 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

View File

@ -0,0 +1,8 @@
.texture {
max-height: 64px;
width: 64px;
}
.line {
width: 80%;
}

View 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

View 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)

View File

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

View 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

View 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
View 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 ''
}

View File

@ -0,0 +1,3 @@
export default function debounce(fn: Function, _?: number) {
return fn
}

View File

@ -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')
})

View File

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

View File

@ -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-...')
})

View File

@ -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')
})

View 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