rewrite skin library with React

This commit is contained in:
Pig Fang 2020-03-24 18:05:46 +08:00
parent 1d6da0eab9
commit c219a0f03f
30 changed files with 1076 additions and 1209 deletions

View File

@ -59,6 +59,11 @@ class ClosetController extends Controller
->paginate(6);
}
public function allIds()
{
return auth()->user()->closet()->pluck('texture_tid');
}
public function add(Request $request)
{
$this->validate($request, [

View File

@ -7,13 +7,12 @@ use App\Models\Texture;
use App\Models\User;
use Auth;
use Blessing\Filter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Option;
use Parsedown;
use Session;
use Storage;
use View;
class SkinlibController extends Controller
{
@ -35,80 +34,42 @@ class SkinlibController extends Controller
8 => 'A PHP extension stopped the file upload.',
];
/**
* Get skin library data filtered.
* Available Query String: filter, uploader, page, sort, keyword, items_per_page.
*/
public function getSkinlibFiltered(Request $request)
public function library(Request $request)
{
$user = Auth::user();
// Available filters: skin, steve, alex, cape
$filter = $request->input('filter', 'skin');
// Filter result by uploader's uid
$uploader = intval($request->input('uploader', 0));
// Current page
$page = $request->input('page', 1);
$currentPage = ($page <= 0) ? 1 : $page;
// How many items to show in one page
$itemsPerPage = $request->input('items_per_page', 20);
$itemsPerPage = $itemsPerPage <= 0 ? 20 : $itemsPerPage;
// Keyword to search
$keyword = $request->input('keyword', '');
if ($filter == 'skin') {
$query = Texture::where(function ($innerQuery) {
// Nested condition, DO NOT MODIFY
$innerQuery->where('type', 'steve')->orWhere('type', 'alex');
});
} else {
$query = Texture::where('type', $filter);
}
if ($keyword !== '') {
$query = $query->like('name', $keyword);
}
if ($uploader !== 0) {
$query = $query->where('uploader', $uploader);
}
if (!$user) {
// Show public textures only to anonymous visitors
$query = $query->where('public', true);
} else {
// Show private textures when show uploaded textures of current user
if ($uploader != $user->uid && !$user->isAdmin()) {
$query = $query->where(function ($innerQuery) use ($user) {
$innerQuery->where('public', true)->orWhere('uploader', '=', $user->uid);
});
}
}
$totalPages = ceil($query->count() / $itemsPerPage);
$type = $request->input('filter', 'skin');
$uploader = $request->input('uploader');
$keyword = $request->input('keyword');
$sort = $request->input('sort', 'time');
$sortBy = $sort == 'time' ? 'upload_at' : $sort;
$query = $query->orderBy($sortBy, 'desc');
$textures = $query->skip(($currentPage - 1) * $itemsPerPage)->take($itemsPerPage)->get();
if ($user) {
$closet = $user->closet()->get();
foreach ($textures as $item) {
$item->liked = $closet->contains('tid', $item->tid);
}
}
return json('', 0, [
'items' => $textures,
'current_uid' => $user ? $user->uid : 0,
'total_pages' => $totalPages,
]);
return Texture::orderBy($sortBy, 'desc')
->when($type === 'skin', function (Builder $query) {
return $query->whereIn('type', ['steve', 'alex']);
}, function (Builder $query) use ($type) {
return $query->where('type', $type);
})
->when($keyword, function (Builder $query, $keyword) {
return $query->like('name', $keyword);
})
->when($uploader, function (Builder $query, $uploader) {
return $query->where('uploader', $uploader);
})
->when($user, function (Builder $query, User $user) {
if (!$user->isAdmin()) {
return $query
->where('public', true)
->orWhere('uploader', $user->uid);
}
}, function (Builder $query) {
// show public textures only to anonymous visitors
return $query->where('public', true);
})
->join('users', 'uid', 'uploader')
->select(['tid', 'name', 'type', 'uploader', 'public', 'likes', 'nickname'])
->paginate(20);
}
public function show(Filter $filter, $tid)

View File

@ -34,6 +34,11 @@ class Texture extends Model
return $query->where($field, 'LIKE', "%$value%");
}
public function owner()
{
return $this->belongsTo(User::class, 'uploader');
}
public function likers()
{
return $this->belongsToMany(User::class, 'user_closet')->withPivot('item_name');

View File

@ -38,7 +38,6 @@
"vue": "^2.6.11",
"vue-good-table": "^2.18.1",
"vue-recaptcha": "^1.2.0",
"vuejs-paginate": "^2.1.0",
"xterm": "^4.4.0",
"xterm-addon-fit": "^0.3.0"
},

View File

@ -1,112 +0,0 @@
<template>
<a class="ml-3 mr-2 mb-2" :href="urlToDetail">
<div class="card skinlib-item">
<div class="card-body texture-img">
<div v-if="!isPublic" class="ribbon-wrapper">
<div class="ribbon bg-pink">{{ $t('skinlib.private') }}</div>
</div>
<img class="card-img-top" :src="urlToPreview">
</div>
<div class="card-footer pb-0 pt-2 pl-1 pr-1">
<div class="container d-flex justify-content-between">
<p>
<span :title="name">{{ name|truncate }}
<small>{{ $t('skinlib.filter.' + type) }}</small>
</span>
</p>
<a
:title="likeActionText"
class="btn-like"
:class="{ liked }"
href="#"
@click.stop="toggleLiked"
>
<i class="fas fa-heart" />
<span>{{ likes }}</span>
</a>
</div>
</div>
</div>
</a>
</template>
<script>
import addClosetItem from './mixins/addClosetItem'
import removeClosetItem from './mixins/removeClosetItem'
import truncateText from './mixins/truncateText'
export default {
name: 'SkinLibItem',
mixins: [
addClosetItem,
removeClosetItem,
truncateText,
],
props: {
tid: Number,
name: String,
type: {
validator: value => ['steve', 'alex', 'cape'].includes(value),
},
liked: Boolean,
likes: Number,
anonymous: Boolean,
isPublic: Boolean, // `public` is a reserved keyword
},
computed: {
urlToDetail() {
return `${blessing.base_url}/skinlib/show/${this.tid}`
},
urlToPreview() {
return `${blessing.base_url}/preview/${this.tid}?height=150`
},
likeActionText() {
if (this.anonymous) {
return this.$t('skinlib.anonymous')
}
return this.liked
? this.$t('skinlib.removeFromCloset')
: this.$t('skinlib.addToCloset')
},
},
methods: {
toggleLiked() {
if (this.anonymous) {
return
}
if (this.liked) {
this.removeFromCloset()
} else {
this.addClosetItem()
}
},
async removeFromCloset() {
this.$once('item-removed', () => this.$emit('like-toggled', false))
await this.removeClosetItem()
},
},
}
</script>
<style lang="stylus">
.skinlib-item
width 245px
transition-property box-shadow
transition-duration 0.3s
&:hover
box-shadow 0 .5rem 1rem rgba(0, 0, 0, 0.15)
.texture-img
background #eff1f0
img
height 210px
.btn-like
color #6c757d
&.liked, &:hover
color #e0353b
</style>

View File

@ -1,36 +0,0 @@
import Vue from 'vue'
import { showModal, toast } from '../../scripts/notify'
import { truthy } from '../../scripts/validators'
export default Vue.extend<{
name: string
tid: number
}, { addClosetItem(): Promise<void> }, {}>({
methods: {
async addClosetItem() {
let value: string
try {
({ value } = await showModal({
mode: 'prompt',
title: this.$t('skinlib.setItemName'),
text: this.$t('skinlib.applyNotice'),
input: this.name,
validator: truthy(this.$t('skinlib.emptyItemName')),
}))
} catch {
return
}
const { code, message } = await this.$http.post(
'/user/closet/add',
{ tid: this.tid, name: value },
)
if (code === 0) {
toast.success(message!)
this.$emit('like-toggled', true)
} else {
toast.error(message!)
}
},
},
})

View File

@ -1,28 +0,0 @@
import Vue from 'vue'
import { showModal, toast } from '../../scripts/notify'
export default Vue.extend<{
name: string
tid: number
}, { removeClosetItem(): Promise<void> }, {}>({
methods: {
async removeClosetItem() {
try {
await showModal({
text: this.$t('user.removeFromClosetNotice'),
okButtonType: 'danger',
})
} catch {
return
}
const { code, message } = await this.$http.post(`/user/closet/remove/${this.tid}`)
if (code === 0) {
this.$emit('item-removed')
toast.success(message!)
} else {
toast.error(message!)
}
},
},
})

View File

@ -1,9 +0,0 @@
import Vue from 'vue'
export default Vue.extend({
filters: {
truncate(text: string = ''): string {
return text.length > 15 ? `${text.slice(0, 15)}...` : text
},
},
})

View File

@ -111,7 +111,7 @@ export default [
},
{
path: 'skinlib',
component: () => import('../views/skinlib/List.vue'),
react: () => import('../views/skinlib/SkinLibrary'),
el: '.content-wrapper',
},
{

View File

@ -1,14 +0,0 @@
export function queryString(key: string, defaultValue: string = ''): string {
const result = new RegExp(`[?&]${key}=([^&]+)`, 'i').exec(location.search)
if (result === null || result.length < 1) {
return defaultValue
}
return result[1]
}
export function queryStringify(params: { [key: string]: string }): string {
return Object.keys(params)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&')
}

View File

@ -1,305 +0,0 @@
<template>
<div class="content-wrapper">
<div class="container">
<!-- Content Header (Page header) -->
<div class="content-header">
<div class="container-fluid d-flex justify-content-between flex-wrap">
<h1>{{ $t('general.skinlib') }}</h1>
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item">
<i class="fas fa-tags" /> {{ $t('skinlib.nowShowing') }}
</li>
<li class="breadcrumb-item">
<span v-if="filter === 'cape'" v-t="'general.cape'" />
<span v-else>
{{ $t('general.skin') }}
{{ $t('skinlib.filter.' + filter) }}
</span>
</li>
<li class="breadcrumb-item">{{ uploaderIndicator }}</li>
<li class="breadcrumb-item active">{{ sortIndicator }}</li>
</ol>
</div>
</div>
<!-- Main content -->
<section class="content">
<div class="card">
<div class="card-body">
<div class="form-group pt-0 mb-3 d-flex justify-content-between">
<form @submit.prevent="submitSearch">
<div class="input-group">
<div class="input-group-prepend">
<button
class="btn btn-default dropdown-toggle"
type="button"
data-toggle="dropdown"
>
{{ filterText }}
</button>
<div class="dropdown-menu">
<button
class="dropdown-item"
:class="{ active: filter === 'skin' }"
@click="filter = 'skin'"
>
{{ $t('general.skin') }}
</button>
<button
class="dropdown-item"
:class="{ active: filter === 'steve' }"
@click="filter = 'steve'"
>
Steve
</button>
<button
class="dropdown-item"
:class="{ active: filter === 'alex' }"
@click="filter = 'alex'"
>
Alex
</button>
<button
class="dropdown-item"
:class="{ active: filter === 'cape' }"
@click="filter = 'cape'"
>
{{ $t('general.cape') }}
</button>
</div>
</div>
<input
v-model="keyword"
class="form-control"
type="text"
data-test="keyword"
:placeholder="$t('vendor.datatable.search')"
>
<div class="input-group-append">
<button
class="btn btn-primary pl-3 pr-3"
data-test="btn-search"
@click="submitSearch"
>
{{ $t('general.submit') }}
</button>
</div>
</div>
</form>
<div class="d-none d-sm-block">
<div class="btn-group">
<button
class="btn bg-olive"
:class="{ active: sort === 'likes' }"
@click="sort = 'likes'"
>
{{ $t('skinlib.sort.likes') }}
</button>
<button
class="btn bg-olive"
:class="{ active: sort === 'time' }"
@click="sort = 'time'"
>
{{ $t('skinlib.sort.time') }}
</button>
<button
class="btn bg-olive"
:class="{ active: uploader === currentUid }"
@click="uploader = currentUid"
>
{{ $t('skinlib.seeMyUpload') }}
</button>
<button class="btn bg-olive" @click="reset">
{{ $t('skinlib.reset') }}
</button>
</div>
</div>
</div>
<div v-if="items.length" class="d-flex flex-wrap">
<skin-lib-item
v-for="(item, index) in items"
:key="item.tid"
:tid="item.tid"
:name="item.name"
:type="item.type"
:liked="item.liked"
:likes="item.likes"
:is-public="item.public"
:anonymous="anonymous"
@like-toggled="onLikeToggled(index, $event)"
/>
</div>
<p v-else class="text-center m-5">
{{ $t('general.noResult') }}
</p>
</div>
<div class="box-footer">
<paginate
v-model="page"
:page-count="totalPages"
class="float-right mr-3"
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 v-show="pending" class="overlay">
<span>
<i class="fas fa-sync-alt fa-spin" />
{{ $t('general.loading') }}
</span>
</div>
</div>
</section>
</div>
</div>
</template>
<script>
import Paginate from 'vuejs-paginate'
import { queryString, queryStringify } from '../../scripts/utils'
import SkinLibItem from '../../components/SkinLibItem.vue'
import emitMounted from '../../components/mixins/emitMounted'
export default {
name: 'SkinLibrary',
components: {
Paginate,
SkinLibItem,
},
mixins: [
emitMounted,
],
data() {
return {
filter: queryString('filter', 'skin'),
uploader: +queryString('uploader', 0),
sort: queryString('sort', 'time'),
keyword: decodeURIComponent(queryString('keyword', '')),
page: +queryString('page', 1),
items: [],
totalPages: 0,
currentUid: 0,
pending: false,
}
},
computed: {
anonymous() {
return !this.currentUid
},
uploaderIndicator() {
return this.uploader
? this.$t('skinlib.filter.uploader', { uid: this.uploader })
: this.$t('skinlib.filter.allUsers')
},
filterText() {
switch (this.filter) {
case 'steve':
return 'Steve'
case 'alex':
return 'Alex'
default:
return this.$t(`general.${this.filter}`)
}
},
sortIndicator() {
return this.$t(`skinlib.sort.${this.sort}`)
},
},
watch: {
filter() {
this.fetchData()
this.updateQueryString()
},
uploader() {
this.fetchData()
this.updateQueryString()
},
sort() {
this.fetchData()
this.updateQueryString()
},
},
beforeMount() {
this.fetchData()
},
methods: {
async fetchData() {
this.pending = true
const {
data: {
items, total_pages: totalPages, current_uid: currentUid,
},
} = await this.$http.get(
'/skinlib/data',
{
filter: this.filter,
uploader: this.uploader,
sort: this.sort,
keyword: this.keyword,
page: this.page,
},
)
this.items = items
this.totalPages = totalPages
this.currentUid = currentUid
this.pending = false
},
updateQueryString() {
const qs = queryStringify({
filter: this.filter,
uploader: this.uploader,
sort: this.sort,
keyword: this.keyword,
page: this.page,
})
window.history.pushState(null, '', `skinlib?${qs}`)
},
submitSearch() {
this.fetchData()
this.updateQueryString()
},
pageChanged(page) {
this.page = page
this.fetchData()
this.updateQueryString()
},
reset() {
this.filter = 'skin'
this.uploader = 0
this.sort = 'time'
this.keyword = ''
this.page = 1
},
onLikeToggled(index, action) {
this.items[index].liked = action
this.items[index].likes += action ? 1 : -1
},
},
}
</script>
<style lang="stylus">
.overlay span
position absolute
top 50%
left 50%
margin-left -40px
margin-top 25px
color #000
font-size 20px
</style>

View File

@ -4,7 +4,7 @@ import { showModal, toast } from '@/scripts/notify'
import { Texture } from '@/scripts/types'
export default async function addClosetItem(
texture: Texture,
texture: Pick<Texture, 'tid' | 'name'>,
): Promise<boolean> {
let name: string
try {

View File

@ -0,0 +1,31 @@
import React from 'react'
interface Props {
active?: boolean
bg?: string
}
type Attributes = React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>
const Button: React.FC<Props & Attributes> = (props) => {
const classes = [props.className ?? '']
if (props.bg) {
classes.push('btn', `bg-${props.bg}`)
}
if (props.active) {
classes.push('active')
}
const rest = { ...props, active: undefined, bg: undefined }
return (
<button {...rest} className={classes.join(' ')}>
{props.children}
</button>
)
}
export default Button

View File

@ -0,0 +1,63 @@
import React from 'react'
import { t } from '@/scripts/i18n'
import Button from './Button'
import { Filter } from './types'
import { humanizeType } from './utils'
interface Props {
filter: Filter
onChange(filter: Filter): void
}
const FilterSelector: React.FC<Props> = (props) => {
const { filter, onChange } = props
const handleSkinClick = () => onChange('skin')
const handleSteveClick = () => onChange('steve')
const handleAlexClick = () => onChange('alex')
const handleCapeClick = () => onChange('cape')
return (
<>
<button
className="btn btn-default dropdown-toggle"
type="button"
data-toggle="dropdown"
>
{humanizeType(filter)}
</button>
<div className="dropdown-menu">
<Button
className="dropdown-item"
active={filter === 'skin'}
onClick={handleSkinClick}
>
{t('general.skin')}
</Button>
<Button
className="dropdown-item"
active={filter === 'steve'}
onClick={handleSteveClick}
>
Steve
</Button>
<Button
className="dropdown-item"
active={filter === 'alex'}
onClick={handleAlexClick}
>
Alex
</Button>
<Button
className="dropdown-item"
active={filter === 'cape'}
onClick={handleCapeClick}
>
{t('general.cape')}
</Button>
</div>
</>
)
}
export default FilterSelector

View File

@ -0,0 +1,21 @@
@use '../../../styles/utils';
.card {
width: 245px;
transition-property: box-shadow;
transition-duration: 0.3s;
&:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
}
.image {
background-color: #eff1f0;
img {
height: 210px;
}
}
.truncate {
@include utils.truncate-text;
}

View File

@ -0,0 +1,79 @@
import React from 'react'
import { t } from '@/scripts/i18n'
import { LibraryItem } from './types'
import { humanizeType } from './utils'
import styles from './Item.module.scss'
interface Props {
item: LibraryItem
liked: boolean
onAdd(texture: LibraryItem): Promise<void>
onRemove(texture: LibraryItem): Promise<void>
onUploaderClick(uploader: number): void
}
const Item: React.FC<Props> = (props) => {
const { item } = props
const link = `${blessing.base_url}/skinlib/show/${item.tid}`
const preview = `${blessing.base_url}/preview/${item.tid}?height=150`
const heartColor = props.liked ? 'text-red' : 'text-gray'
const handleUploaderClick = (event: React.MouseEvent) => {
event.preventDefault()
props.onUploaderClick(item.uploader)
}
const handleHeartClick = () => {
props.liked ? props.onRemove(item) : props.onAdd(item)
}
return (
<div className="ml-3 mr-2 mb-2">
<div className={`card ${styles.card}`}>
<div className={`card-body ${styles.image}`}>
{item.public || (
<div className="ribbon-wrapper">
<div className="ribbon bg-pink">{t('skinlib.private')}</div>
</div>
)}
<a href={link} target="_blank">
<img src={preview} alt={item.name} className="card-img-top" />
</a>
</div>
<div className="card-footer">
<a
className={`d-block mb-1 ${styles.truncate}`}
title={item.name}
href={link}
target="_blank"
>
{item.name}
</a>
<div className="d-flex justify-content-between">
<div>
<span className="badge bg-teal py-1 mr-1">
{humanizeType(item.type)}
</span>
<a
className="badge bg-indigo py-1"
href="#"
title={t('skinlib.show.uploader')}
onClick={handleUploaderClick}
>
{item.nickname}
</a>
</div>
<a href="#" className={heartColor} onClick={handleHeartClick}>
<i className="fas fa-heart mr-1"></i>
{item.likes}
</a>
</div>
</div>
</div>
</div>
)
}
export default Item

View File

@ -0,0 +1,269 @@
import React, { useState, useEffect } from 'react'
import { hot } from 'react-hot-loader/root'
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra'
import { t } from '@/scripts/i18n'
import * as fetch from '@/scripts/net'
import { toast } from '@/scripts/notify'
import { Paginator } from '@/scripts/types'
import Loading from '@/components/Loading'
import Pagination from '@/components/Pagination'
import addClosetItem from '../Show/addClosetItem'
import removeClosetItem from '@/views/user/Closet/removeClosetItem'
import FilterSelector from './FilterSelector'
import Button from './Button'
import Item from './Item'
import { Filter, LibraryItem } from './types'
const SkinLibrary: React.FC = () => {
const [isLoading, setIsLoading] = useState(false)
const [items, setItems] = useState<LibraryItem[]>([])
const [closet, setCloset] = useState<number[]>([])
const [filter, setFilter] = useState<Filter>('skin')
const [name, setName] = useState('')
const [keyword, setKeyword] = useState('')
const [uploader, setUploader] = useState<number | null>(0)
const [sort, setSort] = useState('time')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const currentUid = useBlessingExtra<number | null>('currentUid', null)
useEffect(() => {
const parseSearch = (query: string | URLSearchParams) => {
const search =
typeof query === 'string' ? new URLSearchParams(query) : query
const filter = search.get('filter') ?? ''
setFilter(
['skin', 'steve', 'alex', 'cape'].includes(filter)
? (filter as Filter)
: 'skin',
)
const keyword = decodeURIComponent(search.get('keyword') ?? '')
setName(keyword)
setKeyword(keyword)
const uploader = search.get('uploader') ?? '0'
setUploader(Number.parseInt(uploader))
setSort(search.get('sort') ?? 'time')
}
parseSearch(location.search)
const handler = (event: PopStateEvent) => {
parseSearch(event.state as URLSearchParams)
}
window.addEventListener('popstate', handler)
return () => {
window.removeEventListener('popstate', handler)
}
}, [])
useEffect(() => {
const getItems = async () => {
setIsLoading(true)
const search = new URLSearchParams()
search.append('filter', filter)
if (keyword) {
search.append('keyword', keyword)
}
if (uploader) {
search.append('uploader', uploader.toString())
}
search.append('sort', sort)
search.append('page', page.toString())
window.history.pushState(search, '', `?${search}`)
const result = await fetch.get<Paginator<LibraryItem>>(
'/skinlib/list',
search,
)
setItems(result.data)
setPage(result.current_page)
setTotalPages(result.last_page)
setIsLoading(false)
}
getItems()
}, [filter, keyword, uploader, sort, page])
useEffect(() => {
const getCloset = async () => {
const closet = await fetch.get<number[]>('/user/closet/ids')
setCloset(closet)
}
if (currentUid) {
getCloset()
}
}, [currentUid])
const handleFilterChange = (filter: Filter) => setFilter(filter)
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value)
}
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setKeyword(name)
}
const handleLikesSortClick = () => setSort('likes')
const handleTimeSortClick = () => setSort('time')
const handleSelfUploadClick = () => setUploader(currentUid)
const handleResetClick = () => {
setFilter('skin')
setName('')
setKeyword('')
setSort('time')
setUploader(0)
}
const handleUploaderClick = (uploader: number) => setUploader(uploader)
const handleAddToCloset = async (item: LibraryItem, index: number) => {
if (!currentUid) {
toast.warning(t('skinlib.anonymous'))
return
}
const ok = await addClosetItem(item)
if (ok) {
setCloset((closet) => [...closet, item.tid])
setItems((items) => {
items[index] = { ...item, likes: item.likes + 1 }
return items.slice()
})
}
}
const handleRemoveFromCloset = async (item: LibraryItem, index: number) => {
const ok = await removeClosetItem(item.tid)
if (ok) {
setCloset((closet) => closet.filter((id) => id !== item.tid))
setItems((items) => {
items[index] = { ...item, likes: item.likes - 1 }
return items.slice()
})
}
}
const handlePageChange = (page: number) => setPage(page)
return (
<div className="container">
<div className="content-header">
<div className="container-fluid d-flex justify-content-between">
<h1>{t('general.skinlib')}</h1>
<span>
{uploader ? (
<>
<i className="fas fa-user mr-1"></i>
{t('skinlib.filter.uploader', { uid: uploader })}
</>
) : (
<>
<i className="fas fa-user-friends mr-1"></i>
{t('skinlib.filter.allUsers')}
</>
)}
</span>
</div>
</div>
<section className="content">
<div className="card">
<div className="card-body">
<div className="form-group pt-0 mb-3 d-flex justify-content-between">
<form onSubmit={handleFormSubmit}>
<div className="input-group">
<div className="input-group-prepend">
<FilterSelector
filter={filter}
onChange={handleFilterChange}
/>
</div>
<input
type="text"
className="form-control"
value={name}
placeholder={t('vendor.datatable.search')}
onChange={handleNameChange}
/>
<div className="input-group-append">
<button className="btn btn-primary px-3" type="submit">
{t('general.submit')}
</button>
</div>
</div>
</form>
<div className="d-none d-sm-block">
<div className="btn-group">
<Button
bg="olive"
active={sort === 'likes'}
onClick={handleLikesSortClick}
>
{t('skinlib.sort.likes')}
</Button>
<Button
bg="olive"
active={sort === 'time'}
onClick={handleTimeSortClick}
>
{t('skinlib.sort.time')}
</Button>
{currentUid !== null && (
<Button
bg="olive"
active={uploader === currentUid}
onClick={handleSelfUploadClick}
>
{t('skinlib.seeMyUpload')}
</Button>
)}
<Button bg="olive" onClick={handleResetClick}>
{t('skinlib.reset')}
</Button>
</div>
</div>
</div>
{items.length > 0 ? (
<div className="d-flex flex-wrap">
{items.map((item, i) => (
<Item
key={item.tid}
item={item}
liked={closet.includes(item.tid)}
onAdd={(item) => handleAddToCloset(item, i)}
onRemove={(item) => handleRemoveFromCloset(item, i)}
onUploaderClick={handleUploaderClick}
/>
))}
</div>
) : (
<p className="text-center m-5">{t('general.noResult')}</p>
)}
</div>
<div className="card-footer">
<div className="float-right">
<Pagination
page={page}
totalPages={totalPages}
onChange={handlePageChange}
/>
</div>
</div>
{isLoading && (
<div className="overlay">
<Loading />
</div>
)}
</div>
</section>
</div>
)
}
export default hot(SkinLibrary)

View File

@ -0,0 +1,13 @@
import { TextureType } from '@/scripts/types'
export type Filter = 'skin' | TextureType
export type LibraryItem = {
tid: number
name: string
type: TextureType
uploader: number
public: boolean
likes: number
nickname: string
}

View File

@ -0,0 +1,13 @@
import { t } from '@/scripts/i18n'
import { Filter } from './types'
export function humanizeType(type: Filter): string {
switch (type) {
case 'steve':
return 'Steve'
case 'alex':
return 'Alex'
default:
return t(`general.${type}`)
}
}

View File

@ -1,113 +0,0 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import { flushPromises } from '../utils'
import { showModal, toast } from '@/scripts/notify'
import SkinLibItem from '@/components/SkinLibItem.vue'
jest.mock('@/scripts/notify')
test('urls', () => {
const wrapper = mount(SkinLibItem, {
propsData: { tid: 1 },
})
expect(wrapper.find('a').attributes('href')).toBe('/skinlib/show/1')
expect(wrapper.find('img').attributes('src')).toBe('/preview/1?height=150')
})
test('render basic information', () => {
const wrapper = mount(SkinLibItem, {
propsData: {
tid: 1,
name: 'test',
type: 'steve',
},
})
expect(wrapper.text()).toContain('test')
expect(wrapper.text()).toContain('skinlib.filter.steve')
})
test('anonymous user', () => {
const wrapper = mount(SkinLibItem, {
propsData: { anonymous: true },
})
const button = wrapper.find('.btn-like')
expect(button.attributes('title')).toBe('skinlib.anonymous')
button.trigger('click')
expect(Vue.prototype.$http.post).not.toBeCalled()
})
test('private texture', () => {
const wrapper = mount(SkinLibItem, {
propsData: { isPublic: false },
})
expect(wrapper.text()).toContain('skinlib.private')
wrapper.setProps({ isPublic: true })
expect(wrapper.text()).not.toContain('skinlib.private')
})
test('liked state', () => {
const wrapper = mount(SkinLibItem, {
propsData: { liked: true, anonymous: false },
})
const button = wrapper.find('.btn-like')
expect(button.attributes('title')).toBe('skinlib.removeFromCloset')
expect(button.classes('liked')).toBeTrue()
wrapper.setProps({ liked: false })
expect(button.attributes('title')).toBe('skinlib.addToCloset')
expect(button.classes('liked')).toBeFalse()
})
test('remove from closet', async () => {
Vue.prototype.$http.post.mockResolvedValue({ code: 0 })
showModal.mockResolvedValue({ value: '' })
const wrapper = mount(SkinLibItem, {
propsData: {
tid: 1, liked: true, anonymous: false,
},
})
wrapper.find('.btn-like').trigger('click')
await flushPromises()
expect(wrapper.emitted('like-toggled')[0]).toEqual([false])
})
test('add to closet', async () => {
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 1, message: '1' })
.mockResolvedValue({ code: 0 })
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: 'name' })
const wrapper = mount(SkinLibItem, {
propsData: {
tid: 1, liked: false, anonymous: false,
},
})
const button = wrapper.find('.btn-like')
button.trigger('click')
expect(Vue.prototype.$http.post).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/user/closet/add',
{ tid: 1, name: 'name' },
)
expect(toast.error).toBeCalledWith('1')
button.trigger('click')
await flushPromises()
expect(wrapper.emitted('like-toggled')[0]).toEqual([true])
})
test('truncate too long texture name', () => {
const wrapper = mount(SkinLibItem, {
propsData: {
name: 'very-very-long-texture-name',
},
})
expect(wrapper.text()).toContain('very-very-long-...')
})

View File

@ -1,12 +0,0 @@
import * as utils from '@/scripts/utils'
test('queryString', () => {
history.pushState({}, 'page', `${location.href}?key=value`)
expect(utils.queryString('key')).toBe('value')
expect(utils.queryString('a')).toBe('')
expect(utils.queryString('a', 'b')).toBe('b')
})
test('queryStringify', () => {
expect(utils.queryStringify({ a: 'b', c: 'd' })).toBe('a=b&c=d')
})

View File

@ -1,262 +0,0 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import { flushPromises } from '../../utils'
import { trans } from '@/scripts/i18n'
import { queryString } from '@/scripts/utils'
import List from '@/views/skinlib/List.vue'
beforeEach(() => {
window.history.pushState(null, '', 'skinlib')
})
test('fetch data before mounting', () => {
Vue.prototype.$http.get.mockResolvedValue({
data: {
items: [], total_pages: 0, current_uid: 0,
},
})
mount(List)
expect(Vue.prototype.$http.get).toBeCalledWith(
'/skinlib/data',
{
filter: 'skin', uploader: 0, sort: 'time', keyword: '', page: 1,
},
)
})
test('empty skin library', () => {
Vue.prototype.$http.get.mockResolvedValue({
data: {
items: [], total_pages: 0, current_uid: 0,
},
})
const wrapper = mount(List)
expect(wrapper.text()).toContain(trans('general.noResult'))
})
test('toggle texture type', () => {
Vue.prototype.$http.get.mockResolvedValue({
data: {
items: [], total_pages: 0, current_uid: 0,
},
})
const wrapper = mount(List)
const options = wrapper.findAll('.dropdown-item')
const btnSkin = options.at(0)
const btnSteve = options.at(1)
const btnAlex = options.at(2)
const btnCape = options.at(3)
const dropdownToggle = wrapper.find('.dropdown-toggle')
const breadcrumb = wrapper.find('.breadcrumb')
expect(btnSkin.classes()).toContain('active')
expect(btnSteve.classes()).not.toContain('active')
expect(btnAlex.classes()).not.toContain('active')
expect(btnCape.classes()).not.toContain('active')
expect(dropdownToggle.text()).toContain(trans('general.skin'))
expect(breadcrumb.text()).toContain(trans('skinlib.filter.skin'))
btnSteve.trigger('click')
expect(btnSkin.classes()).not.toContain('active')
expect(btnSteve.classes()).toContain('active')
expect(btnAlex.classes()).not.toContain('active')
expect(btnCape.classes()).not.toContain('active')
expect(dropdownToggle.text()).toContain('Steve')
expect(breadcrumb.text()).toContain(trans('skinlib.filter.steve'))
expect(queryString('filter')).toBe('steve')
expect(Vue.prototype.$http.get).toBeCalledWith(
'/skinlib/data',
{
filter: 'steve', uploader: 0, sort: 'time', keyword: '', page: 1,
},
)
btnAlex.trigger('click')
expect(btnSkin.classes()).not.toContain('active')
expect(btnSteve.classes()).not.toContain('active')
expect(btnAlex.classes()).toContain('active')
expect(btnCape.classes()).not.toContain('active')
expect(dropdownToggle.text()).toContain('Alex')
expect(breadcrumb.text()).toContain(trans('skinlib.filter.alex'))
expect(Vue.prototype.$http.get).toBeCalledWith(
'/skinlib/data',
{
filter: 'alex', uploader: 0, sort: 'time', keyword: '', page: 1,
},
)
expect(queryString('filter')).toBe('alex')
btnCape.trigger('click')
expect(btnSkin.classes()).not.toContain('active')
expect(btnSteve.classes()).not.toContain('active')
expect(btnAlex.classes()).not.toContain('active')
expect(btnCape.classes()).toContain('active')
expect(dropdownToggle.text()).toContain(trans('general.cape'))
expect(breadcrumb.text()).toContain(trans('general.cape'))
expect(Vue.prototype.$http.get).toBeCalledWith(
'/skinlib/data',
{
filter: 'cape', uploader: 0, sort: 'time', keyword: '', page: 1,
},
)
expect(queryString('filter')).toBe('cape')
})
test('check specified uploader', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: {
items: [], total_pages: 0, current_uid: 1,
},
})
const wrapper = mount(List)
await flushPromises()
const breadcrumb = wrapper.find('.breadcrumb')
const button = wrapper.findAll('.bg-olive').at(2)
expect(breadcrumb.text()).toContain(trans('skinlib.filter.allUsers'))
button.trigger('click')
expect(button.classes()).toContain('active')
expect(breadcrumb.text()).toContain(trans('skinlib.filter.uploader', { uid: 1 }))
expect(Vue.prototype.$http.get).toBeCalledWith(
'/skinlib/data',
{
filter: 'skin', uploader: 1, sort: 'time', keyword: '', page: 1,
},
)
expect(queryString('uploader')).toBe('1')
})
test('sort items', () => {
Vue.prototype.$http.get.mockResolvedValue({
data: {
items: [], total_pages: 0, current_uid: 0,
},
})
const wrapper = mount(List)
const buttons = wrapper.findAll('.bg-olive')
const sortByLikes = buttons.at(0)
const sortByTime = buttons.at(1)
sortByLikes.trigger('click')
expect(Vue.prototype.$http.get).toBeCalledWith(
'/skinlib/data',
{
filter: 'skin', uploader: 0, sort: 'likes', keyword: '', page: 1,
},
)
expect(wrapper.text()).toContain(trans('skinlib.sort.likes'))
expect(sortByLikes.classes()).toContain('active')
expect(queryString('sort')).toBe('likes')
sortByTime.trigger('click')
expect(Vue.prototype.$http.get).toBeCalledWith(
'/skinlib/data',
{
filter: 'skin', uploader: 0, sort: 'time', keyword: '', page: 1,
},
)
expect(wrapper.text()).toContain(trans('skinlib.sort.time'))
expect(sortByTime.classes()).toContain('active')
expect(queryString('sort')).toBe('time')
})
test('search by keyword', () => {
Vue.prototype.$http.get.mockResolvedValue({
data: {
items: [], total_pages: 0, current_uid: 0,
},
})
const wrapper = mount(List)
const input = wrapper.find('[data-test="keyword"]')
input.setValue('a')
wrapper.find('form').trigger('submit')
expect(Vue.prototype.$http.get).toBeCalledWith(
'/skinlib/data',
{
filter: 'skin', uploader: 0, sort: 'time', keyword: 'a', page: 1,
},
)
expect(queryString('keyword')).toBe('a')
input.setValue('b')
wrapper.find('[data-test="btn-search"]').trigger('click')
expect(Vue.prototype.$http.get).toBeCalledWith(
'/skinlib/data',
{
filter: 'skin', uploader: 0, sort: 'time', keyword: 'b', page: 1,
},
)
expect(queryString('keyword')).toBe('b')
})
test('reset all filters', () => {
Vue.prototype.$http.get.mockResolvedValue({
data: {
items: [], total_pages: 0, current_uid: 0,
},
})
const wrapper = mount(List)
wrapper
.findAll('.dropdown-item')
.at(3)
.trigger('click')
wrapper.setData({ keyword: 'abc' })
const buttons = wrapper.findAll('.bg-olive')
buttons.at(1).trigger('click')
Vue.prototype.$http.get.mockClear()
buttons.at(3).trigger('click')
expect(Vue.prototype.$http.get).toBeCalledTimes(1)
})
test('is anonymous', () => {
Vue.prototype.$http.get.mockResolvedValue({
data: {
items: [], total_pages: 0, current_uid: 0,
},
})
const wrapper = mount<Vue & { anonymous: boolean }>(List)
expect(wrapper.vm.anonymous).toBeTrue()
})
test('on page changed', () => {
Vue.prototype.$http.get.mockResolvedValue({
data: {
items: [], total_pages: 0, current_uid: 0,
},
})
const wrapper = mount<Vue & { pageChanged(page: number): void }>(List)
wrapper.vm.pageChanged(2)
expect(Vue.prototype.$http.get).toBeCalledWith(
'/skinlib/data',
{
filter: 'skin', uploader: 0, sort: 'time', keyword: '', page: 2,
},
)
expect(queryString('page')).toBe('2')
})
test('on like toggled', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: {
items: [{
tid: 1, liked: false, likes: 0,
}],
total_pages: 1,
current_uid: 0,
},
})
const wrapper = mount<Vue & {
onLikeToggled(tid: number, like: boolean): void
items: Array<{ liked: boolean, likes: number }>
}>(List)
await flushPromises()
wrapper.vm.onLikeToggled(0, true)
expect(wrapper.vm.items[0].liked).toBeTrue()
expect(wrapper.vm.items[0].likes).toBe(1)
wrapper.vm.onLikeToggled(0, false)
expect(wrapper.vm.items[0].liked).toBeFalse()
expect(wrapper.vm.items[0].likes).toBe(0)
})

View File

@ -0,0 +1,433 @@
import React from 'react'
import { render, fireEvent, wait } from '@testing-library/react'
import { t } from '@/scripts/i18n'
import * as fetch from '@/scripts/net'
import { Paginator } from '@/scripts/types'
import SkinLibrary from '@/views/skinlib/SkinLibrary'
import { LibraryItem } from '@/views/skinlib/SkinLibrary/types'
jest.mock('@/scripts/net')
const fixtureItem: Readonly<LibraryItem> = Object.freeze<LibraryItem>({
tid: 1,
name: 'my skin',
type: 'steve',
uploader: 1,
nickname: 'me',
public: true,
likes: 70,
})
function createPaginator(data: LibraryItem[]): Paginator<LibraryItem> {
return {
data,
total: data.length,
from: 1,
to: data.length,
current_page: 1,
last_page: 1,
}
}
beforeEach(() => {
window.blessing.extra = { currentUid: null }
})
test('without authenticated', async () => {
fetch.get.mockResolvedValue(createPaginator([]))
const { queryByText } = render(<SkinLibrary />)
await wait()
expect(fetch.get).toBeCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('filter')).toBe('skin')
expect(search.get('sort')).toBe('time')
expect(search.get('page')).toBe('1')
return true
}),
)
expect(fetch.get).not.toBeCalledWith('/user/closet/ids')
expect(queryByText(t('skinlib.seeMyUpload'))).not.toBeInTheDocument()
})
test('search by keyword', async () => {
fetch.get.mockResolvedValue(createPaginator([]))
const { getByText, getByPlaceholderText } = render(<SkinLibrary />)
await wait()
fireEvent.input(getByPlaceholderText(t('vendor.datatable.search')), {
target: { value: 'k' },
})
fireEvent.click(getByText(t('general.submit')))
await wait()
expect(fetch.get).toHaveBeenLastCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('keyword')).toBe('k')
return true
}),
)
})
test('select uploaded by self', async () => {
window.blessing.extra.currentUid = 1
fetch.get.mockResolvedValue(createPaginator([]))
const { getByText, queryByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText(t('skinlib.seeMyUpload')))
await wait()
expect(fetch.get).toHaveBeenLastCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('uploader')).toBe('1')
return true
}),
)
expect(queryByText(t('skinlib.filter.uploader', { uid: 1 })))
})
test('reset query', async () => {
window.blessing.extra.currentUid = 1
fetch.get.mockResolvedValue(createPaginator([]))
const { getByText, getByPlaceholderText, queryByText } = render(
<SkinLibrary />,
)
await wait()
fireEvent.click(getByText('Steve'))
await wait()
fireEvent.input(getByPlaceholderText(t('vendor.datatable.search')), {
target: { value: 'k' },
})
fireEvent.click(getByText(t('general.submit')))
await wait()
fireEvent.click(getByText(t('skinlib.seeMyUpload')))
await wait()
fireEvent.click(getByText(t('skinlib.sort.likes')))
await wait()
fireEvent.click(getByText(t('skinlib.reset')))
await wait()
expect(fetch.get).toHaveBeenLastCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('filter')).toBe('skin')
expect(search.get('keyword')).toBeNull()
expect(search.get('uploader')).toBeNull()
expect(search.get('sort')).toBe('time')
expect(search.get('page')).toBe('1')
return true
}),
)
expect(queryByText(t('skinlib.filter.uploader', { uid: 1 })))
})
test('browser goes back', async () => {
fetch.get.mockResolvedValue(createPaginator([]))
const { getByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText('Steve'))
await wait()
const state: URLSearchParams = window.history.state
state.set('filter', 'skin')
const event = new PopStateEvent('popstate', { state })
window.dispatchEvent(event)
await wait()
expect(fetch.get).toHaveBeenLastCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('filter')).toBe('skin')
return true
}),
)
})
test('pagination', async () => {
const response = { ...createPaginator([]), last_page: 2 }
fetch.get.mockResolvedValue(response)
const { getByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText('2'))
expect(fetch.get).toHaveBeenLastCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('page')).toBe('2')
return true
}),
)
})
test('library item', async () => {
fetch.get.mockResolvedValue(createPaginator([fixtureItem]))
const { getByText, queryByText, queryAllByText, queryByAltText } = render(
<SkinLibrary />,
)
await wait()
expect(queryAllByText('Steve')).toHaveLength(2)
expect(queryByText(fixtureItem.name)).toBeInTheDocument()
expect(queryByAltText(fixtureItem.name)).toHaveAttribute(
'src',
`/preview/${fixtureItem.tid}?height=150`,
)
expect(queryByText(fixtureItem.nickname)).toBeInTheDocument()
fireEvent.click(getByText(fixtureItem.nickname))
await wait()
expect(fetch.get).toHaveBeenLastCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('uploader')).toBe(fixtureItem.uploader.toString())
return true
}),
)
const search = new URLSearchParams(location.search)
expect(search.get('uploader')).toBe(fixtureItem.uploader.toString())
})
test('private texture', async () => {
const item = { ...fixtureItem, public: false }
fetch.get.mockResolvedValue(createPaginator([item]))
const { queryByText } = render(<SkinLibrary />)
await wait()
expect(queryByText(t('skinlib.private'))).toBeInTheDocument()
})
describe('by filter', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([]))
})
it('skin', async () => {
const { getByText, queryAllByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText('Steve'))
await wait()
fireEvent.click(getByText(t('general.skin')))
await wait()
expect(fetch.get).toHaveBeenLastCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('filter')).toBe('skin')
return true
}),
)
expect(queryAllByText(t('general.skin'))).toHaveLength(2)
const search = new URLSearchParams(location.search)
expect(search.get('filter')).toBe('skin')
})
it('steve', async () => {
const { getByText, queryAllByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText('Steve'))
await wait()
expect(fetch.get).toHaveBeenLastCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('filter')).toBe('steve')
return true
}),
)
expect(queryAllByText('Steve')).toHaveLength(2)
const search = new URLSearchParams(location.search)
expect(search.get('filter')).toBe('steve')
})
it('alex', async () => {
const { getByText, queryAllByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText('Alex'))
await wait()
expect(fetch.get).toHaveBeenLastCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('filter')).toBe('alex')
return true
}),
)
expect(queryAllByText('Alex')).toHaveLength(2)
const search = new URLSearchParams(location.search)
expect(search.get('filter')).toBe('alex')
})
it('cape', async () => {
const { getByText, queryAllByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText(t('general.cape')))
await wait()
expect(fetch.get).toHaveBeenLastCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('filter')).toBe('cape')
return true
}),
)
expect(queryAllByText(t('general.cape'))).toHaveLength(2)
const search = new URLSearchParams(location.search)
expect(search.get('filter')).toBe('cape')
})
})
describe('sorting', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([]))
})
it('by time', async () => {
const { getByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText(t('skinlib.sort.likes')))
await wait()
fireEvent.click(getByText(t('skinlib.sort.time')))
await wait()
expect(fetch.get).toHaveBeenLastCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('sort')).toBe('time')
return true
}),
)
const search = new URLSearchParams(location.search)
expect(search.get('sort')).toBe('time')
})
it('by likes', async () => {
const { getByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText(t('skinlib.sort.likes')))
await wait()
expect(fetch.get).toHaveBeenLastCalledWith(
'/skinlib/list',
expect.toSatisfy((search: URLSearchParams) => {
expect(search.get('sort')).toBe('likes')
return true
}),
)
const search = new URLSearchParams(location.search)
expect(search.get('sort')).toBe('likes')
})
})
describe('add to closet', () => {
beforeEach(() => {
fetch.get.mockImplementation((url: string) => {
if (url === '/skinlib/list') {
return Promise.resolve(createPaginator([fixtureItem]))
} else {
return Promise.resolve([])
}
})
})
it('without authenticated', async () => {
const { getByText, queryByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText(fixtureItem.likes.toString()))
expect(queryByText(t('skinlib.anonymous'))).toBeInTheDocument()
expect(fetch.post).not.toBeCalled()
})
it('succeeded', async () => {
window.blessing.extra.currentUid = 1
fetch.post.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, queryByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText(fixtureItem.likes.toString()))
fireEvent.click(getByText(t('general.confirm')))
await wait()
expect(fetch.post).toBeCalled()
expect(queryByText((fixtureItem.likes + 1).toString())).toBeInTheDocument()
})
it('failed', async () => {
window.blessing.extra.currentUid = 1
fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
const { getByText, queryByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText(fixtureItem.likes.toString()))
fireEvent.click(getByText(t('general.confirm')))
await wait()
expect(fetch.post).toBeCalled()
expect(queryByText(fixtureItem.likes.toString())).toBeInTheDocument()
})
})
describe('remove from closet', () => {
beforeEach(() => {
window.blessing.extra.currentUid = 1
fetch.get.mockImplementation((url: string) => {
if (url === '/skinlib/list') {
return Promise.resolve(createPaginator([fixtureItem]))
} else {
return Promise.resolve([fixtureItem.tid])
}
})
})
it('succeeded', async () => {
fetch.post.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, queryByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText(fixtureItem.likes.toString()))
fireEvent.click(getByText(t('general.confirm')))
await wait()
expect(fetch.post).toBeCalled()
expect(queryByText((fixtureItem.likes - 1).toString())).toBeInTheDocument()
})
it('failed', async () => {
fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
const { getByText, queryByText } = render(<SkinLibrary />)
await wait()
fireEvent.click(getByText(fixtureItem.likes.toString()))
fireEvent.click(getByText(t('general.confirm')))
await wait()
expect(fetch.post).toBeCalled()
expect(queryByText(fixtureItem.likes.toString())).toBeInTheDocument()
})
})

View File

@ -26,6 +26,7 @@
- 3D skin viewer can be with background now.
- Added support of installing plugin by uploading archive.
- Added support of installing plugin by submitting remote URL.
- Added support of clicking on the uploader's nickname in skin library to view other uploads of that user.
## Tweaked
@ -66,6 +67,7 @@
- Fixed when uploading duplicated texture, alert is missing.
- Fixed that "score cost per closet item" isn't calculated at "texture upload" page.
- Fixed that administrator can't add private texture to his/her closet.
- Fixed that button "See My Upload" existed when user isn't authenticated.
## Removed
@ -85,6 +87,7 @@
- Removed cache for Profile JSON.
- Removed cache for existence of player.
- Removed settings of "Respond 204 for unexisted players". (Install plugin if you need it.)
- Removed breadcrumb of skin library.
## Internal Changes

View File

@ -26,6 +26,7 @@
- 3D 皮肤预览现在是带背景的
- 可通过上传压缩包来安装插件
- 可通过提交 URL 来安装插件
- 皮肤库中可通过点击上传者昵称来查看该用户的其它上传
## 调整
@ -66,6 +67,7 @@
- 修复上传重复材质时没有提示用户的问题
- 「材质上传」页面的积分消耗没有计算衣柜收藏所需的积分
- 修复管理员不能添加私有材质到衣柜的问题
- 修复未登录的用户在浏览皮肤库时出现「我的上传」按钮问题
## 移除
@ -85,6 +87,7 @@
- 移除对 Profile JSON 的缓存
- 移除对角色存在与否的缓存
- 移除「对不存在的角色返回 204」的选项如有需要请安装插件
- 移除皮肤库右上角的 breadcrumb
## 内部更改

View File

@ -5,3 +5,9 @@
{% block content %}
<div class="content-wrapper"></div>
{% endblock %}
{% block before_foot %}
<script>
blessing.extra = {{ {currentUid: auth_user().uid}|json_encode|raw }}
</script>
{% endblock %}

View File

@ -85,6 +85,7 @@ Route::prefix('user')
Route::prefix('closet')->name('closet.')->group(function () {
Route::get('', 'ClosetController@index')->name('page');
Route::get('list', 'ClosetController@getClosetData')->name('list');
Route::get('ids', 'ClosetController@allIds')->name('ids');
Route::post('add', 'ClosetController@add')->name('add');
Route::post('remove/{tid}', 'ClosetController@remove')->name('remove');
Route::post('rename/{tid}', 'ClosetController@rename')->name('rename');
@ -96,9 +97,9 @@ Route::prefix('user')
Route::prefix('skinlib')->name('skinlib.')->group(function () {
Route::view('', 'skinlib.index')->name('home');
Route::any('info/{tid}', 'SkinlibController@info')->name('info');
Route::any('show/{tid}', 'SkinlibController@show')->name('show');
Route::any('data', 'SkinlibController@getSkinlibFiltered')->name('list');
Route::get('info/{tid}', 'SkinlibController@info')->name('info');
Route::get('show/{tid}', 'SkinlibController@show')->name('show');
Route::get('list', 'SkinlibController@library')->name('list');
Route::middleware(['authorize', 'verified'])->group(function () {
Route::prefix('upload')->name('upload')->group(function () {

View File

@ -65,6 +65,17 @@ class ClosetControllerTest extends TestCase
]]);
}
public function testAllIds()
{
$texture = factory(Texture::class)->create();
$user = factory(User::class)->create();
$user->closet()->attach($texture->tid, ['item_name' => '']);
$this->actingAs($user)
->getJson(route('user.closet.ids'))
->assertJson([$texture->tid]);
}
public function testAdd()
{
$uploader = factory(User::class)->create(['score' => 0]);

View File

@ -6,6 +6,7 @@ use App\Models\Player;
use App\Models\Texture;
use App\Models\User;
use Blessing\Filter;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
@ -15,250 +16,95 @@ class SkinlibControllerTest extends TestCase
{
use DatabaseTransactions;
public function testGetSkinlibFiltered()
public function testLibrary()
{
$this->getJson('/skinlib/data')
->assertJson(['data' => [
'items' => [],
'current_uid' => 0,
'total_pages' => 0,
]]);
$steve = factory(Texture::class)->create([
'name' => 'ab',
'upload_at' => Carbon::now()->subDays(2),
'likes' => 80,
]);
$alex = factory(Texture::class)->states('alex')->create([
'name' => 'cd',
'upload_at' => Carbon::now()->subDays(1),
'likes' => 60,
]);
$private = factory(Texture::class)->states('private')->create([
'upload_at' => Carbon::now(),
]);
$steves = factory(Texture::class, 5)->create();
$alexs = factory(Texture::class, 5)->states('alex')->create();
$skins = $steves->merge($alexs);
$capes = factory(Texture::class, 5)->states('cape')->create();
// Default arguments
$items = $this->getJson('/skinlib/data')
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 1,
]])
->decodeResponseJson('data')['items'];
$this->assertCount(10, $items);
$this->assertTrue(collect($items)->every(function ($item) {
return $item['type'] == 'steve' || $item['type'] == 'alex';
}));
// Only steve
$items = $this->getJson('/skinlib/data?filter=steve')
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 1,
]])
->decodeResponseJson('data')['items'];
$this->assertCount(5, $items);
$this->assertTrue(collect($items)->every(function ($item) {
return $item['type'] == 'steve';
}));
// Invalid type
$this->getJson('/skinlib/data?filter=what')
->assertJson(['data' => [
'items' => [],
'current_uid' => 0,
'total_pages' => 0,
]]);
// Only capes
$items = $this->getJson('/skinlib/data?filter=cape')
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 1,
]])
->decodeResponseJson('data')['items'];
$this->assertCount(5, $items);
$this->assertTrue(collect($items)->every(function ($item) {
return $item['type'] == 'cape';
}));
// Only specified uploader
$uid = $skins->random()->uploader;
$owned = $skins
->filter(function ($texture) use ($uid) {
return $texture->uploader == $uid;
});
$items = $this->getJson('/skinlib/data?uploader='.$uid)
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 1,
]])
->decodeResponseJson('data')['items'];
$this->assertCount($owned->count(), $items);
$this->assertTrue(collect($items)->every(function ($item) use ($uid) {
return $item['uploader'] == $uid;
}));
// Sort by `tid`
$ordered = $skins->sortByDesc('tid')->map(function ($skin) {
return $skin->tid;
})->values()->all();
$items = $this->getJson('/skinlib/data?sort=tid')
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 1,
]])
->decodeResponseJson('data')['items'];
$items = array_map(function ($item) {
return $item['tid'];
}, $items);
$this->assertEquals($ordered, $items);
// Search
$keyword = Str::limit($skins->random()->name, 1, '');
$keyworded = $skins
->filter(function ($texture) use ($keyword) {
return Str::contains($texture->name, [$keyword, strtolower($keyword)]);
});
$items = $this->getJson('/skinlib/data?keyword='.$keyword)
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 1,
]])
->decodeResponseJson('data')['items'];
$this->assertCount($keyworded->count(), $items);
// More than one argument
$keyword = Str::limit($skins->random()->name, 1, '');
$filtered = $skins
->filter(function ($texture) use ($keyword) {
return Str::contains($texture->name, [$keyword, strtolower($keyword)]);
})
->sortByDesc('size')
->map(function ($skin) {
return $skin->tid;
})
->values()
->all();
$items = $this->getJson('/skinlib/data?sort=size&keyword='.$keyword)
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 1,
]])
->decodeResponseJson('data')['items'];
$items = array_map(function ($item) {
return $item['tid'];
}, $items);
$this->assertCount(count($filtered), $items);
$this->assertEquals($filtered, $items);
// Pagination
$steves = factory(Texture::class)
->times(15)
->create()
->merge($steves);
$skins = $steves->merge($alexs);
$items = $this->getJson('/skinlib/data')
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 2,
]])
->decodeResponseJson('data')['items'];
$this->assertCount(20, $items);
$items = $this->getJson('/skinlib/data?page=-5')
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 2,
]])
->decodeResponseJson('data')['items'];
$this->assertCount(20, $items);
$page2Count = $skins->forPage(2, 20)->count();
$items = $this->getJson('/skinlib/data?page=2')
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 2,
]])
->decodeResponseJson('data')['items'];
$this->assertCount(5, $items);
$this->getJson('/skinlib/data?page=8')
->assertJson(['data' => [
'items' => [],
'current_uid' => 0,
'total_pages' => 2,
]]);
$items = $this->getJson('/skinlib/data?items_per_page=-6&page=2')
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 2,
]])
->decodeResponseJson('data')['items'];
$this->assertCount($page2Count, $items);
$page3Count = $skins->forPage(3, 8)->count();
$items = $this->getJson('/skinlib/data?page=3&items_per_page=8')
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 4,
]])
->decodeResponseJson('data')['items'];
$this->assertCount($page3Count, $items);
// Add some private textures
$uploader = factory(User::class)->create();
$otherUser = factory(User::class)->create();
$private = factory(Texture::class)
->times(5)
->create(['public' => false, 'uploader' => $uploader->uid]);
// If not logged in, private textures should not be shown
$items = $this->getJson('/skinlib/data')
->assertJson(['data' => [
'current_uid' => 0,
'total_pages' => 2,
]])
->decodeResponseJson('data')['items'];
$this->assertTrue(collect($items)->every(function ($item) {
return $item['public'] == true;
}));
// Other users should not see someone's private textures
$items = $this->actingAs($otherUser)
->getJson('/skinlib/data')
->assertJson(['data' => [
'current_uid' => $otherUser->uid,
'total_pages' => 2,
]])
->decodeResponseJson('data')['items'];
$this->assertTrue(collect($items)->every(function ($item) {
return !$item['liked'];
}));
// A user has added a texture from skin library to his closet
$texture = $skins->sortByDesc('upload_at')->values()->first();
$otherUser->closet()->attach($texture->tid, ['item_name' => $texture->name]);
$this->getJson('/skinlib/data')
->assertJson(['data' => [
'items' => [
['tid' => $texture->tid, 'liked' => true],
// default
$this->getJson('/skinlib/list')
->assertJson([
'data' => [
['tid' => $alex->tid, 'nickname' => $alex->owner->nickname],
['tid' => $steve->tid, 'nickname' => $steve->owner->nickname],
],
'current_uid' => $otherUser->uid,
'total_pages' => 2,
]]);
]);
// Uploader can see his private textures
$items = $this->actingAs($uploader)
->getJson('/skinlib/data')
->assertJson(['data' => [
'current_uid' => $uploader->uid,
'total_pages' => 2,
]])
->decodeResponseJson('data')['items'];
$this->assertTrue(collect($items)->contains(function ($item) {
return $item['public'] == false;
}));
// with filter
$this->getJson('/skinlib/list?filter=steve')
->assertJson([
'data' => [
['tid' => $steve->tid, 'nickname' => $steve->owner->nickname],
],
]);
// Administrators can see private textures
$admin = factory(User::class)->states('admin')->create();
$items = $this->actingAs($admin)
->getJson('/skinlib/data')
->assertJson(['data' => [
'current_uid' => $admin->uid,
'total_pages' => 2,
]])
->decodeResponseJson('data')['items'];
$this->assertTrue(collect($items)->contains(function ($item) {
return $item['public'] == false;
}));
// with keyword
$this->getJson('/skinlib/list?keyword=a')
->assertJson([
'data' => [
['tid' => $steve->tid, 'nickname' => $steve->owner->nickname],
],
]);
// with uploader
$this->getJson('/skinlib/list?uploader='.$steve->uploader)
->assertJson([
'data' => [
['tid' => $steve->tid, 'nickname' => $steve->owner->nickname],
],
]);
// sort by likes
$this->getJson('/skinlib/list?sort=likes')
->assertJson([
'data' => [
['tid' => $steve->tid, 'nickname' => $steve->owner->nickname],
['tid' => $alex->tid, 'nickname' => $alex->owner->nickname],
],
]);
// private textures are not available for other user
$this->actingAs(factory(User::class)->create())
->getJson('/skinlib/list')
->assertJson([
'data' => [
['tid' => $alex->tid, 'nickname' => $alex->owner->nickname],
['tid' => $steve->tid, 'nickname' => $steve->owner->nickname],
],
]);
// private textures are available for uploader
$this->actingAs($private->owner)
->getJson('/skinlib/list')
->assertJson([
'data' => [
['tid' => $private->tid],
['tid' => $alex->tid],
['tid' => $steve->tid],
],
]);
// private textures are available for administrators
$this->actingAs(factory(User::class)->states('admin')->create())
->getJson('/skinlib/list')
->assertJson([
'data' => [
['tid' => $private->tid],
['tid' => $alex->tid],
['tid' => $steve->tid],
],
]);
}
public function testShow()

View File

@ -10438,10 +10438,6 @@ vue@^2.6.11:
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.11.tgz#76594d877d4b12234406e84e35275c6d514125c5"
integrity sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ==
vuejs-paginate@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/vuejs-paginate/-/vuejs-paginate-2.1.0.tgz#93e1ad1539b713a688c7a2d3080bda60fcc6c77d"
w3c-hr-time@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045"