rewrite skin library with React
This commit is contained in:
parent
1d6da0eab9
commit
c219a0f03f
@ -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, [
|
||||
|
@ -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)
|
||||
|
@ -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');
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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>
|
@ -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!)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
@ -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!)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
@ -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
|
||||
},
|
||||
},
|
||||
})
|
@ -111,7 +111,7 @@ export default [
|
||||
},
|
||||
{
|
||||
path: 'skinlib',
|
||||
component: () => import('../views/skinlib/List.vue'),
|
||||
react: () => import('../views/skinlib/SkinLibrary'),
|
||||
el: '.content-wrapper',
|
||||
},
|
||||
{
|
||||
|
@ -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('&')
|
||||
}
|
@ -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>
|
@ -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 {
|
||||
|
31
resources/assets/src/views/skinlib/SkinLibrary/Button.tsx
Normal file
31
resources/assets/src/views/skinlib/SkinLibrary/Button.tsx
Normal 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
|
@ -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
|
@ -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;
|
||||
}
|
79
resources/assets/src/views/skinlib/SkinLibrary/Item.tsx
Normal file
79
resources/assets/src/views/skinlib/SkinLibrary/Item.tsx
Normal 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
|
269
resources/assets/src/views/skinlib/SkinLibrary/index.tsx
Normal file
269
resources/assets/src/views/skinlib/SkinLibrary/index.tsx
Normal 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)
|
13
resources/assets/src/views/skinlib/SkinLibrary/types.ts
Normal file
13
resources/assets/src/views/skinlib/SkinLibrary/types.ts
Normal 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
|
||||
}
|
13
resources/assets/src/views/skinlib/SkinLibrary/utils.ts
Normal file
13
resources/assets/src/views/skinlib/SkinLibrary/utils.ts
Normal 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}`)
|
||||
}
|
||||
}
|
@ -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-...')
|
||||
})
|
@ -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')
|
||||
})
|
@ -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)
|
||||
})
|
433
resources/assets/tests/views/skinlib/SkinLibrary.test.tsx
Normal file
433
resources/assets/tests/views/skinlib/SkinLibrary.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
@ -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
|
||||
|
||||
|
@ -26,6 +26,7 @@
|
||||
- 3D 皮肤预览现在是带背景的
|
||||
- 可通过上传压缩包来安装插件
|
||||
- 可通过提交 URL 来安装插件
|
||||
- 皮肤库中可通过点击上传者昵称来查看该用户的其它上传
|
||||
|
||||
## 调整
|
||||
|
||||
@ -66,6 +67,7 @@
|
||||
- 修复上传重复材质时没有提示用户的问题
|
||||
- 「材质上传」页面的积分消耗没有计算衣柜收藏所需的积分
|
||||
- 修复管理员不能添加私有材质到衣柜的问题
|
||||
- 修复未登录的用户在浏览皮肤库时出现「我的上传」按钮问题
|
||||
|
||||
## 移除
|
||||
|
||||
@ -85,6 +87,7 @@
|
||||
- 移除对 Profile JSON 的缓存
|
||||
- 移除对角色存在与否的缓存
|
||||
- 移除「对不存在的角色返回 204」的选项(如有需要,请安装插件)
|
||||
- 移除皮肤库右上角的 breadcrumb
|
||||
|
||||
## 内部更改
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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 () {
|
||||
|
@ -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]);
|
||||
|
@ -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()
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user