Add skin library "show" page

This commit is contained in:
Pig Fang 2018-08-16 16:34:27 +08:00
parent d39f95d634
commit deda5cf11c
6 changed files with 671 additions and 120 deletions

View File

@ -59,6 +59,11 @@ export default [
component: () => import('./skinlib/List'),
el: '.content-wrapper'
},
{
path: 'skinlib/show/(\\d+)',
component: () => import('./skinlib/Show'),
el: '.content > .row:nth-child(1)'
},
{
path: 'skinlib/upload',
component: () => import('./skinlib/Upload'),

View File

@ -0,0 +1,308 @@
<template>
<div class="row">
<div class="col-md-8">
<previewer
:skin="type !== 'cape' && textureUrl"
:cape="type === 'cape' ? textureUrl : ''"
:init-position-z="60"
>
<template slot="footer">
<button
v-if="!auth"
disabled
:title="$t('skinlib.show.anonymous')"
class="btn btn-primary pull-right"
v-t="'skinlib.addToCloset'"
></button>
<template v-else>
<a
v-if="liked"
@click="removeFromCloset"
class="btn btn-primary pull-right"
v-t="'skinlib.removeFromCloset'"
></a>
<a
v-else
@click="addToCloset"
class="btn btn-primary pull-right"
v-t="'skinlib.addToCloset'"
></a>
</template>
<div
class="btn likes"
style="cursor: auto;"
:style="{ color: liked ? '#e0353b' : '#333' }"
:title="$t('skinlib.show.likes')"
data-toggle="tooltip"
data-placement="top"
>
<i class="fas fa-heart"></i>
<span>{{ likes }}</span>
</div>
</template>
</previewer>
</div>
<div class="col-md-4">
<div class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title" v-t="'skinlib.show.detail'"></h3>
</div>
<div class="box-body">
<table class="table">
<tbody>
<tr>
<td v-t="'skinlib.show.name'"></td>
<td>{{ name }}
<small v-if="uploader === currentUid || admin">
<a href="#" @click="changeTextureName" v-t="'skinlib.show.edit-name'"></a>
</small>
</td>
</tr>
<tr>
<td v-t="'skinlib.show.model'"></td>
<td>
<template v-if="type === 'cape'">{{ $t('general.cape') }}</template>
<template v-else>{{ type }}</template>
</td>
</tr>
<tr>
<td>TID</td>
<td>{{ tid }}</td>
</tr>
<tr>
<td>Hash
<i
v-if="canBeDownloaded"
class="fas fa-question-circle"
:title="$t('skinlib.show.download-raw')"
data-toggle="tooltip"
data-placement="top"
></i>
</td>
<td>
<a v-if="canBeDownloaded" :href="`${baseUrl}/raw/${tid}.png`" :title="hash">{{ hash.slice(0, 15) }}...</a>
<span v-else :title="hash">{{ hash.slice(0, 15) }}...</span>
</td>
</tr>
<tr>
<td v-t="'skinlib.show.size'"></td>
<td>{{ size }} KB</td>
</tr>
<tr>
<td v-t="'skinlib.show.uploader'"></td>
<template v-if="uploaderNickName != null">
<td><a :href="`${baseUrl}/skinlib?filter=${type === 'cape' ? 'cape' : 'skin'}&uploader=${uploader}`">{{ uploaderNickName }}</a></td>
</template>
<template v-else>
<td><span v-t="'general.unexistent-user'"></span></td>
</template>
</tr>
<tr>
<td v-t="'skinlib.show.upload-at'"></td>
<td>{{ uploadAt }}</td>
</tr>
</tbody>
</table>
</div><!-- /.box-body -->
</div><!-- /.box -->
<div v-if="auth" class="box box-warning">
<div class="box-header with-border">
<h3 class="box-title" v-t="'admin.operationsTitle'" />
</div><!-- /.box-header -->
<div class="box-body">
<p v-t="'skinlib.show.manage-notice'"></p>
</div><!-- /.box-body -->
<div class="box-footer">
<a @click="togglePrivacy" class="btn btn-warning" v-t="togglePrivacyText"></a>
<a @click="deleteTexture" class="btn btn-danger pull-right" v-t="'skinlib.show.delete-texture'"></a>
</div><!-- /.box-footer -->
</div>
</div>
</div>
</template>
<script>
import { swal } from '../../js/notify';
import toastr from 'toastr';
export default {
name: 'Show',
components: {
Previewer: () => import('../common/Previewer')
},
props: {
baseUrl: {
default: blessing.base_url
}
},
data() {
return {
tid: +this.$route[1],
name: '',
type: 'steve',
likes: 0,
hash: '',
uploader: 0,
size: 0,
uploadAt: '',
public: true,
liked: __bs_data__.inCloset,
canBeDownloaded: __bs_data__.download,
currentUid: __bs_data__.currentUid,
admin: __bs_data__.admin,
uploaderNickName: __bs_data__.nickname,
};
},
computed: {
auth() {
return !!this.currentUid;
},
togglePrivacyText() {
return this.public ? 'skinlib.setAsPrivate' : 'skinlib.setAsPublic';
},
textureUrl() {
return `${this.baseUrl}/textures/${this.hash}`;
}
},
beforeMount() {
this.fetchData();
},
methods: {
async fetchData() {
const data = await this.$http.get(`/skinlib/info/${this.tid}`);
this.name = data.name;
this.type = data.type;
this.likes = data.likes;
this.hash = data.hash;
this.uploader = data.uploader;
this.size = data.size;
this.uploadAt = data.upload_at;
this.public = !!data.public;
},
async addToCloset() {
const { dismiss, value } = await swal({
title: this.$t('skinlib.setItemName'),
inputValue: this.name,
input: 'text',
showCancelButton: true,
inputValidator: value => !value && this.$t('skinlib.emptyItemName')
});
if (dismiss) {
return;
}
const { errno, msg } = await this.$http.post(
'/user/closet/add',
{ tid: this.tid, name: value }
);
if (errno === 0) {
this.liked = true;
this.likes++;
swal({ type: 'success', text: msg });
} else {
toastr.warning(msg);
}
},
async removeFromCloset() {
const { dismiss } = await swal({
text: this.$t('user.removeFromClosetNotice'),
type: 'warning',
showCancelButton: true,
cancelButtonColor: '#3085d6',
confirmButtonColor: '#d33'
});
if (dismiss) {
return;
}
const { errno, msg } = await this.$http.post(
'/user/closet/remove',
{ tid: this.tid }
);
if (errno === 0) {
this.liked = false;
this.likes--;
swal({ type: 'success', text: msg });
} else {
toastr.warning(msg);
}
},
async changeTextureName() {
const { dismiss, value } = await swal({
text: this.$t('skinlib.setNewTextureName'),
input: 'text',
inputValue: this.name,
showCancelButton: true,
inputValidator: name => !name && this.$t('skinlib.emptyNewTextureName')
});
if (dismiss) {
return;
}
const { errno, msg } = await this.$http.post(
'/skinlib/rename',
{ tid: this.tid, new_name: value }
);
if (errno === 0) {
this.name = value;
toastr.success(msg);
} else {
toastr.warning(msg);
}
},
async togglePrivacy() {
const { dismiss } = await swal({
text: this.public
? this.$t('skinlib.setPrivateNotice')
: this.$t('skinlib.setPublicNotice'),
type: 'warning',
showCancelButton: true
});
if (dismiss) {
return;
}
const { errno, msg } = await this.$http.post(
'/skinlib/privacy',
{ tid: this.tid }
);
if (errno === 0) {
toastr.success(msg);
this.public = !this.public;
} else {
toastr.warning(msg);
}
},
async deleteTexture() {
const { dismiss } = await swal({
text: this.$t('skinlib.deleteNotice'),
type: 'warning',
showCancelButton: true
});
if (dismiss) {
return;
}
const { errno, msg } = await this.$http.post(
'/skinlib/delete',
{ tid: this.tid }
);
if (errno === 0) {
await swal({ type: 'success', text: msg });
window.location = `${this.baseUrl}/skinlib`;
} else {
swal({ type: 'warning', text: msg });
}
}
}
};
</script>
<style lang="stylus">
.table > tbody > tr > td {
border-top: 0;
}
</style>

View File

@ -0,0 +1,317 @@
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import Show from '@/components/skinlib/Show';
import { flushPromises } from '../../utils';
import { swal } from '@/js/notify';
import toastr from 'toastr';
jest.mock('@/js/notify');
window.__bs_data__ = {
download: true,
currentUid: 0,
admin: false,
nickname: 'author',
inCloset: false,
};
/** @type {import('Vue').ComponentOptions} */
const previewer = {
render(h) {
return h('div', this.$slots.footer);
},
};
test('button for adding to closet should be disabled if not auth', () => {
Vue.prototype.$http.get.mockResolvedValue({});
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
},
stubs: { previewer }
});
expect(wrapper.find('.btn-primary').attributes()).toHaveProperty('disabled', 'disabled');
});
test('button for adding to closet should be disabled if auth', () => {
Vue.prototype.$http.get.mockResolvedValue({});
Object.assign(window.__bs_data__, { inCloset: true, currentUid: 1 });
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
},
stubs: { previewer }
});
expect(wrapper.find('.btn-primary').text()).toBe('skinlib.removeFromCloset');
});
test('likes count indicator', async () => {
Vue.prototype.$http.get.mockResolvedValue({ likes: 2 });
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
},
stubs: { previewer }
});
await wrapper.vm.$nextTick();
expect(wrapper.find('.likes').attributes().style).toContain('color: rgb(224, 53, 59)');
expect(wrapper.find('.likes').text()).toContain('2');
});
test('render basic information', async () => {
Vue.prototype.$http.get.mockResolvedValue({
name: 'my-texture',
type: 'alex',
hash: '123',
size: 2,
upload_at: '2018'
});
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
}
});
await wrapper.vm.$nextTick();
const text = wrapper.find('.box-primary').text();
expect(text).toContain('my-texture');
expect(text).toContain('alex');
expect(text).toContain('123...');
expect(text).toContain('2 KB');
expect(text).toContain('2018');
expect(text).toContain('author');
});
test('render action text of editing texture name', async () => {
Object.assign(window.__bs_data__, { admin: true });
Vue.prototype.$http.get.mockResolvedValue({ uploader: 1, name: 'name' });
let wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
}
});
await wrapper.vm.$nextTick();
expect(wrapper.contains('small')).toBeTrue();
Object.assign(window.__bs_data__, { currentUid: 2, admin: false });
wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
}
});
await wrapper.vm.$nextTick();
expect(wrapper.contains('small')).toBeFalse();
});
test('render nickname of uploader', () => {
Object.assign(window.__bs_data__, { nickname: null });
Vue.prototype.$http.get.mockResolvedValue({});
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
}
});
expect(wrapper.text()).toContain('general.unexistent-user');
});
test('operation panel should not be rendered if not auth', () => {
Object.assign(window.__bs_data__, { currentUid: 0 });
Vue.prototype.$http.get.mockResolvedValue({});
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
}
});
expect(wrapper.contains('.box-warning')).toBeFalse();
});
test('link to downloading texture', async () => {
Object.assign(window.__bs_data__, { download: false });
Vue.prototype.$http.get.mockResolvedValue({ hash: '123' });
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
}
});
await wrapper.vm.$nextTick();
expect(wrapper.contains('a[title="123"]')).toBeFalse();
expect(wrapper.contains('span[title="123"]')).toBeTrue();
});
test('add to closet', async () => {
Object.assign(window.__bs_data__, { currentUid: 1, inCloset: false });
Vue.prototype.$http.get.mockResolvedValue({ name: 'wow', likes: 2 });
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1' })
.mockResolvedValue({ errno: 0, msg: '' });
jest.spyOn(toastr, 'warning');
swal.mockImplementationOnce(() => ({ dismiss: 1 }))
.mockImplementation(({ inputValidator }) => {
if (inputValidator) {
inputValidator();
inputValidator('wow');
}
return { value: 'wow' };
});
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
},
stubs: { previewer }
});
const button = wrapper.find('.btn-primary');
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: 'wow' }
);
expect(toastr.warning).toBeCalledWith('1');
button.trigger('click');
await flushPromises();
expect(wrapper.vm.likes).toBe(3);
expect(wrapper.vm.liked).toBeTrue();
});
test('remove from closet', async () => {
Object.assign(window.__bs_data__, { currentUid: 1, inCloset: true });
Vue.prototype.$http.get.mockResolvedValue({ likes: 2 });
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1' })
.mockResolvedValue({ errno: 0, msg: '' });
jest.spyOn(toastr, 'warning');
swal.mockResolvedValueOnce({ dismiss: 1 })
.mockResolvedValue({});
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
},
stubs: { previewer }
});
const button = wrapper.find('.btn-primary');
button.trigger('click');
expect(Vue.prototype.$http.post).not.toBeCalled();
button.trigger('click');
await flushPromises();
expect(Vue.prototype.$http.post).toBeCalledWith(
'/user/closet/remove',
{ tid: 1 }
);
expect(toastr.warning).toBeCalledWith('1');
button.trigger('click');
await flushPromises();
expect(wrapper.vm.likes).toBe(1);
expect(wrapper.vm.liked).toBeFalse();
});
test('change texture name', async () => {
Object.assign(window.__bs_data__, { admin: true });
Vue.prototype.$http.get.mockResolvedValue({ name: 'old-name' });
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1' })
.mockResolvedValue({ errno: 0, msg: '0' });
jest.spyOn(toastr, 'warning');
swal.mockImplementationOnce(() => ({ dismiss: 1 }))
.mockImplementation(({ inputValidator }) => {
inputValidator();
inputValidator('new-name');
return { value: 'new-name' };
});
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
},
stubs: { previewer }
});
const button = wrapper.find('small > a');
button.trigger('click');
expect(Vue.prototype.$http.post).not.toBeCalled();
button.trigger('click');
await flushPromises();
expect(Vue.prototype.$http.post).toBeCalledWith(
'/skinlib/rename',
{ tid: 1, new_name: 'new-name' }
);
expect(toastr.warning).toBeCalledWith('1');
button.trigger('click');
await flushPromises();
expect(wrapper.vm.name).toBe('new-name');
});
test('toggle privacy', async () => {
Vue.prototype.$http.get.mockResolvedValue({ public: true });
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1' })
.mockResolvedValue({ errno: 0, msg: '0' });
jest.spyOn(toastr, 'warning');
swal.mockResolvedValueOnce({ dismiss: 1 })
.mockResolvedValue({});
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
},
stubs: { previewer }
});
const button = wrapper.find('.btn-warning');
button.trigger('click');
expect(Vue.prototype.$http.post).not.toBeCalled();
button.trigger('click');
await flushPromises();
expect(Vue.prototype.$http.post).toBeCalledWith(
'/skinlib/privacy',
{ tid: 1 }
);
expect(toastr.warning).toBeCalledWith('1');
button.trigger('click');
await flushPromises();
expect(wrapper.vm.public).toBeFalse();
button.trigger('click');
await flushPromises();
expect(wrapper.vm.public).toBeTrue();
});
test('delete texture', async () => {
Vue.prototype.$http.get.mockResolvedValue({});
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1' })
.mockResolvedValue({ errno: 0, msg: '0' });
swal.mockResolvedValueOnce({ dismiss: 1 })
.mockResolvedValue({});
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1']
},
stubs: { previewer }
});
const button = wrapper.find('.btn-danger');
button.trigger('click');
expect(Vue.prototype.$http.post).not.toBeCalled();
button.trigger('click');
await flushPromises();
expect(Vue.prototype.$http.post).toBeCalledWith(
'/skinlib/delete',
{ tid: 1 }
);
expect(swal).toBeCalledWith({ type: 'warning', text: '1' });
button.trigger('click');
await flushPromises();
expect(swal).toBeCalledWith({ type: 'success', text: '0' });
});

View File

@ -68,6 +68,7 @@ skinlib:
setAsPrivate: Set as Private
setAsPublic: Set as Public
setPublicNotice: Sure to set this as public texture?
setPrivateNotice: Sure to set this as private texture?
deleteNotice: Are you sure to delete this texture?
upload:
texture-name: Texture Name
@ -79,6 +80,19 @@ skinlib:
dropZone: Drop a file here
remove: Remove
cost: (It cost you about :score score)
show:
anonymous: You must login to use closets
likes: People who like this
detail: Details
name: Texture Name
edit-name: Edit Name
model: Applicable Model
download-raw: Click to download raw texture
size: File Size
uploader: Uploader
upload-at: Upload At
delete-texture: Delete Texture
notice: The texture which was deleted or setted to private will be removed from the closet of everyone who had favorited it.
user:
signRemainingTime: 'Available after :time :unit'

View File

@ -70,6 +70,7 @@ skinlib:
setAsPrivate: 设为隐私
setAsPublic: 设为公开
setPublicNotice: 要将此材质设置为公开吗?
setPrivateNotice: 要将此材质设置为私有吗?
deleteNotice: 真的要删除此材质吗?
upload:
texture-name: 材质名称
@ -81,6 +82,19 @@ skinlib:
dropZone: 拖拽文件到这里
remove: 移除
cost: (这会消耗您约 :score 积分)
show:
anonymous: 登录后才能使用衣柜哦
likes: 收藏人数
detail: 详细信息
name: 名称
edit-name: 修改名称
model: 适用模型
download-raw: 右键另存为即可下载原始皮肤文件
size: 文件大小
uploader: 上传者
upload-at: 上传日期
delete-texture: 删除材质
manage-notice: 材质设为隐私或被删除后将会从每一个收藏者的衣柜中移除。
user:
signRemainingTime: ':time :unit 后可签到'

View File

@ -15,108 +15,7 @@
<!-- Main content -->
<section class="content">
<div class="row">
<div class="col-md-8">
<div class="box box-primary">
@include('common.texture-preview')
<div class="box-footer">
@if (is_null($user)) {{-- Not logged in --}}
<button disabled="disabled" title="@lang('skinlib.show.anonymous')" class="btn btn-primary pull-right">@lang('skinlib.item.add-to-closet')</button>
@else
@if ($user->getCloset()->has($texture->tid))
<a onclick="removeFromCloset({{ $texture->tid }});" id="{{ $texture->tid }}" class="btn btn-primary pull-right">@lang('skinlib.item.remove-from-closet')</a>
@else
<a onclick="addToCloset({{ $texture->tid }});" id="{{ $texture->tid }}" class="btn btn-primary pull-right">@lang('skinlib.item.add-to-closet')</a>
@endif
@endif
<div class="btn likes" title="@lang('skinlib.show.likes')" data-toggle="tooltip" data-placement="top"><i class="fas fa-heart"></i>
<span id="likes">{{ $texture->likes }}</span>
</div>
</div><!-- /.box-footer -->
</div>
</div>
<div class="col-md-4">
<div class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title">@lang('skinlib.show.detail')</h3>
</div><!-- /.box-header -->
<div class="box-body">
<table class="table">
<tbody>
<tr>
<td>@lang('skinlib.show.name')</td>
<td id="name">{{ $texture->name }}
@if (!is_null($user) && ($texture->uploader == $user->uid || $user->isAdmin()))
<small>
<a style="cursor: pointer" onclick="changeTextureName({{ $texture->tid }}, '{{ $texture->name }}');">@lang('skinlib.show.edit-name')</a>
</small>
@endif
</td>
</tr>
<tr>
<td>@lang('skinlib.show.model')</td>
<td>
@if ($texture->type == 'cape')
@lang('general.cape')
@else
{{ $texture->type }}
@endif
</td>
</tr>
<tr>
<td>Hash
@if (option('allow_downloading_texture'))
<i class="fas fa-question-circle" title="@lang('skinlib.show.download-raw')" data-toggle="tooltip" data-placement="top"></i>
@endif
</td>
<td>
@if (option('allow_downloading_texture'))
<a href="{{ url('raw/'.$texture->tid) }}.png" title="{{ $texture->hash }}">{{ substr($texture->hash, 0, 15) }}...</a>
@else
<span title="{{ $texture->hash }}">{{ substr($texture->hash, 0, 15) }}...</span>
@endif
</td>
</tr>
<tr>
<td>@lang('skinlib.show.size')</td>
<td>{{ $texture->size }} KB</td>
</tr>
<tr>
<td>@lang('skinlib.show.uploader')</td>
@if ($uploader = app('users')->get($texture->uploader))
<td><a href="{{ url('skinlib?filter='.($texture->type == 'cape' ? 'cape' : 'skin').'&uploader='.$uploader->uid) }}&sort=time">{{ $uploader->getNickName() }}</a></td>
@else
<td><a href="#">@lang('general.unexistent-user')</a></td>
@endif
</tr>
<tr>
<td>@lang('skinlib.show.upload-at')</td>
<td>{{ $texture->upload_at }}</td>
</tr>
</tbody>
</table>
</div><!-- /.box-body -->
</div><!-- /.box -->
@if (!is_null($user))
@if ($texture->uploader == $user->uid)
@include('common.manage-panel', [
'title' => trans('skinlib.show.delete-texture')." / ".trans('skinlib.privacy.change-privacy'),
'message' => trans('skinlib.show.notice')
])
@elseif ($user->isAdmin())
@include('common.manage-panel', [
'title' => trans('skinlib.show.manage-panel'),
'message' => trans('skinlib.show.notice-admin')
])
@endif
@endif
</div>
</div>
<div class="row"></div>
<div class="row">
<div class="col-md-12">
@ -141,25 +40,19 @@
</div><!-- /.container -->
</div><!-- /.content-wrapper -->
@endsection
@section('script')
<script>
var texture = {!! $texture->toJson() !!};
$.msp.config.slim = (texture.type === 'alex');
$.msp.config.skinUrl = texture.type === 'alex' ? defaultAlexSkin : defaultSteveSkin;
if (texture.type === 'cape') {
$.msp.config.capeUrl = url('textures/' + texture.hash);
} else {
$.msp.config.skinUrl = url('textures/' + texture.hash);
Object.defineProperty(window, '__bs_data__', {
configurable: false,
get: function () {
return Object.freeze({
download: {{ option('allow_downloading_texture') ? 'true' : 'false' }},
currentUid: {{ is_null($user) ? '0' : $user->uid }},
admin: {{ $user && $user->isAdmin() ? 'true' : 'false' }},
inCloset: {{ $user && $user->getCloset()->has($texture->tid) ? 'true' : 'false' }},
nickname: @php echo ($up = app('users')->get($texture->uploader)) ? '"'.$up->nickname.'"' : 'null' @endphp
})
}
$(document).ready(function () {
initSkinViewer(60);
registerAnimationController();
registerWindowResizeHandler();
});
})
</script>
@endsection