diff --git a/app/Http/Controllers/ClosetController.php b/app/Http/Controllers/ClosetController.php index 6c278302..87025916 100644 --- a/app/Http/Controllers/ClosetController.php +++ b/app/Http/Controllers/ClosetController.php @@ -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, [ diff --git a/app/Http/Controllers/SkinlibController.php b/app/Http/Controllers/SkinlibController.php index ff939d55..1662183f 100644 --- a/app/Http/Controllers/SkinlibController.php +++ b/app/Http/Controllers/SkinlibController.php @@ -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) diff --git a/app/Models/Texture.php b/app/Models/Texture.php index f22dcc71..6cc86c8f 100644 --- a/app/Models/Texture.php +++ b/app/Models/Texture.php @@ -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'); diff --git a/package.json b/package.json index d28a636d..3bd98458 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/resources/assets/src/components/SkinLibItem.vue b/resources/assets/src/components/SkinLibItem.vue deleted file mode 100644 index 7c428717..00000000 --- a/resources/assets/src/components/SkinLibItem.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - - - diff --git a/resources/assets/src/components/mixins/addClosetItem.ts b/resources/assets/src/components/mixins/addClosetItem.ts deleted file mode 100644 index 603727dc..00000000 --- a/resources/assets/src/components/mixins/addClosetItem.ts +++ /dev/null @@ -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 }, {}>({ - 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!) - } - }, - }, -}) diff --git a/resources/assets/src/components/mixins/removeClosetItem.ts b/resources/assets/src/components/mixins/removeClosetItem.ts deleted file mode 100644 index a17b8de4..00000000 --- a/resources/assets/src/components/mixins/removeClosetItem.ts +++ /dev/null @@ -1,28 +0,0 @@ -import Vue from 'vue' -import { showModal, toast } from '../../scripts/notify' - -export default Vue.extend<{ - name: string - tid: number -}, { removeClosetItem(): Promise }, {}>({ - 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!) - } - }, - }, -}) diff --git a/resources/assets/src/components/mixins/truncateText.ts b/resources/assets/src/components/mixins/truncateText.ts deleted file mode 100644 index ea5a645d..00000000 --- a/resources/assets/src/components/mixins/truncateText.ts +++ /dev/null @@ -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 - }, - }, -}) diff --git a/resources/assets/src/scripts/route.tsx b/resources/assets/src/scripts/route.tsx index 363ed62f..e2c5e4cc 100644 --- a/resources/assets/src/scripts/route.tsx +++ b/resources/assets/src/scripts/route.tsx @@ -111,7 +111,7 @@ export default [ }, { path: 'skinlib', - component: () => import('../views/skinlib/List.vue'), + react: () => import('../views/skinlib/SkinLibrary'), el: '.content-wrapper', }, { diff --git a/resources/assets/src/scripts/utils.ts b/resources/assets/src/scripts/utils.ts deleted file mode 100644 index f801f1a1..00000000 --- a/resources/assets/src/scripts/utils.ts +++ /dev/null @@ -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('&') -} diff --git a/resources/assets/src/views/skinlib/List.vue b/resources/assets/src/views/skinlib/List.vue deleted file mode 100644 index 1ea601de..00000000 --- a/resources/assets/src/views/skinlib/List.vue +++ /dev/null @@ -1,305 +0,0 @@ - - - - - diff --git a/resources/assets/src/views/skinlib/Show/addClosetItem.ts b/resources/assets/src/views/skinlib/Show/addClosetItem.ts index 7b9927e6..fd4eda9f 100644 --- a/resources/assets/src/views/skinlib/Show/addClosetItem.ts +++ b/resources/assets/src/views/skinlib/Show/addClosetItem.ts @@ -4,7 +4,7 @@ import { showModal, toast } from '@/scripts/notify' import { Texture } from '@/scripts/types' export default async function addClosetItem( - texture: Texture, + texture: Pick, ): Promise { let name: string try { diff --git a/resources/assets/src/views/skinlib/SkinLibrary/Button.tsx b/resources/assets/src/views/skinlib/SkinLibrary/Button.tsx new file mode 100644 index 00000000..623c43d6 --- /dev/null +++ b/resources/assets/src/views/skinlib/SkinLibrary/Button.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +interface Props { + active?: boolean + bg?: string +} + +type Attributes = React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement +> + +const Button: React.FC = (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 ( + + ) +} + +export default Button diff --git a/resources/assets/src/views/skinlib/SkinLibrary/FilterSelector.tsx b/resources/assets/src/views/skinlib/SkinLibrary/FilterSelector.tsx new file mode 100644 index 00000000..b390266a --- /dev/null +++ b/resources/assets/src/views/skinlib/SkinLibrary/FilterSelector.tsx @@ -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) => { + const { filter, onChange } = props + + const handleSkinClick = () => onChange('skin') + const handleSteveClick = () => onChange('steve') + const handleAlexClick = () => onChange('alex') + const handleCapeClick = () => onChange('cape') + + return ( + <> + +
+ + + + +
+ + ) +} + +export default FilterSelector diff --git a/resources/assets/src/views/skinlib/SkinLibrary/Item.module.scss b/resources/assets/src/views/skinlib/SkinLibrary/Item.module.scss new file mode 100644 index 00000000..860df5cc --- /dev/null +++ b/resources/assets/src/views/skinlib/SkinLibrary/Item.module.scss @@ -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; +} diff --git a/resources/assets/src/views/skinlib/SkinLibrary/Item.tsx b/resources/assets/src/views/skinlib/SkinLibrary/Item.tsx new file mode 100644 index 00000000..6f716d3d --- /dev/null +++ b/resources/assets/src/views/skinlib/SkinLibrary/Item.tsx @@ -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 + onRemove(texture: LibraryItem): Promise + onUploaderClick(uploader: number): void +} + +const Item: React.FC = (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 ( +
+
+
+ {item.public || ( +
+
{t('skinlib.private')}
+
+ )} + + {item.name} + +
+
+ + {item.name} + +
+
+ + {humanizeType(item.type)} + + + {item.nickname} + +
+ + + {item.likes} + +
+
+
+
+ ) +} + +export default Item diff --git a/resources/assets/src/views/skinlib/SkinLibrary/index.tsx b/resources/assets/src/views/skinlib/SkinLibrary/index.tsx new file mode 100644 index 00000000..c17ac664 --- /dev/null +++ b/resources/assets/src/views/skinlib/SkinLibrary/index.tsx @@ -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([]) + const [closet, setCloset] = useState([]) + const [filter, setFilter] = useState('skin') + const [name, setName] = useState('') + const [keyword, setKeyword] = useState('') + const [uploader, setUploader] = useState(0) + const [sort, setSort] = useState('time') + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const currentUid = useBlessingExtra('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>( + '/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('/user/closet/ids') + setCloset(closet) + } + if (currentUid) { + getCloset() + } + }, [currentUid]) + + const handleFilterChange = (filter: Filter) => setFilter(filter) + + const handleNameChange = (event: React.ChangeEvent) => { + setName(event.target.value) + } + + const handleFormSubmit = (event: React.FormEvent) => { + 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 ( +
+
+
+

{t('general.skinlib')}

+ + {uploader ? ( + <> + + {t('skinlib.filter.uploader', { uid: uploader })} + + ) : ( + <> + + {t('skinlib.filter.allUsers')} + + )} + +
+
+
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+ + + {currentUid !== null && ( + + )} + +
+
+
+ {items.length > 0 ? ( +
+ {items.map((item, i) => ( + handleAddToCloset(item, i)} + onRemove={(item) => handleRemoveFromCloset(item, i)} + onUploaderClick={handleUploaderClick} + /> + ))} +
+ ) : ( +

{t('general.noResult')}

+ )} +
+
+
+ +
+
+ {isLoading && ( +
+ +
+ )} +
+
+
+ ) +} + +export default hot(SkinLibrary) diff --git a/resources/assets/src/views/skinlib/SkinLibrary/types.ts b/resources/assets/src/views/skinlib/SkinLibrary/types.ts new file mode 100644 index 00000000..80726fde --- /dev/null +++ b/resources/assets/src/views/skinlib/SkinLibrary/types.ts @@ -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 +} diff --git a/resources/assets/src/views/skinlib/SkinLibrary/utils.ts b/resources/assets/src/views/skinlib/SkinLibrary/utils.ts new file mode 100644 index 00000000..67bb5f48 --- /dev/null +++ b/resources/assets/src/views/skinlib/SkinLibrary/utils.ts @@ -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}`) + } +} diff --git a/resources/assets/tests/components/SkinLibItem.test.ts b/resources/assets/tests/components/SkinLibItem.test.ts deleted file mode 100644 index fc78fd6c..00000000 --- a/resources/assets/tests/components/SkinLibItem.test.ts +++ /dev/null @@ -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-...') -}) diff --git a/resources/assets/tests/scripts/utils.test.ts b/resources/assets/tests/scripts/utils.test.ts deleted file mode 100644 index 4586faf4..00000000 --- a/resources/assets/tests/scripts/utils.test.ts +++ /dev/null @@ -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') -}) diff --git a/resources/assets/tests/views/skinlib/List.test.ts b/resources/assets/tests/views/skinlib/List.test.ts deleted file mode 100644 index 601b9874..00000000 --- a/resources/assets/tests/views/skinlib/List.test.ts +++ /dev/null @@ -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(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(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 - }>(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) -}) diff --git a/resources/assets/tests/views/skinlib/SkinLibrary.test.tsx b/resources/assets/tests/views/skinlib/SkinLibrary.test.tsx new file mode 100644 index 00000000..4df86bdb --- /dev/null +++ b/resources/assets/tests/views/skinlib/SkinLibrary.test.tsx @@ -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 = Object.freeze({ + tid: 1, + name: 'my skin', + type: 'steve', + uploader: 1, + nickname: 'me', + public: true, + likes: 70, +}) + +function createPaginator(data: LibraryItem[]): Paginator { + 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() + 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() + 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() + 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( + , + ) + 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() + 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() + 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( + , + ) + 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() + await wait() + + expect(queryByText(t('skinlib.private'))).toBeInTheDocument() +}) + +describe('by filter', () => { + beforeEach(() => { + fetch.get.mockResolvedValue(createPaginator([])) + }) + + it('skin', async () => { + const { getByText, queryAllByText } = render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + }) +}) diff --git a/resources/misc/changelogs/en/5.0.0.md b/resources/misc/changelogs/en/5.0.0.md index 9fa3d47d..a586e13d 100644 --- a/resources/misc/changelogs/en/5.0.0.md +++ b/resources/misc/changelogs/en/5.0.0.md @@ -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 diff --git a/resources/misc/changelogs/zh_CN/5.0.0.md b/resources/misc/changelogs/zh_CN/5.0.0.md index e3ef17c1..8d9c89ca 100644 --- a/resources/misc/changelogs/zh_CN/5.0.0.md +++ b/resources/misc/changelogs/zh_CN/5.0.0.md @@ -26,6 +26,7 @@ - 3D 皮肤预览现在是带背景的 - 可通过上传压缩包来安装插件 - 可通过提交 URL 来安装插件 +- 皮肤库中可通过点击上传者昵称来查看该用户的其它上传 ## 调整 @@ -66,6 +67,7 @@ - 修复上传重复材质时没有提示用户的问题 - 「材质上传」页面的积分消耗没有计算衣柜收藏所需的积分 - 修复管理员不能添加私有材质到衣柜的问题 +- 修复未登录的用户在浏览皮肤库时出现「我的上传」按钮问题 ## 移除 @@ -85,6 +87,7 @@ - 移除对 Profile JSON 的缓存 - 移除对角色存在与否的缓存 - 移除「对不存在的角色返回 204」的选项(如有需要,请安装插件) +- 移除皮肤库右上角的 breadcrumb ## 内部更改 diff --git a/resources/views/skinlib/index.twig b/resources/views/skinlib/index.twig index 518e6dde..1b3366e8 100644 --- a/resources/views/skinlib/index.twig +++ b/resources/views/skinlib/index.twig @@ -5,3 +5,9 @@ {% block content %}
{% endblock %} + +{% block before_foot %} + +{% endblock %} diff --git a/routes/web.php b/routes/web.php index 80a09cec..f99cc7c8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 () { diff --git a/tests/HttpTest/ControllersTest/ClosetControllerTest.php b/tests/HttpTest/ControllersTest/ClosetControllerTest.php index cf08f3cb..1a93d433 100644 --- a/tests/HttpTest/ControllersTest/ClosetControllerTest.php +++ b/tests/HttpTest/ControllersTest/ClosetControllerTest.php @@ -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]); diff --git a/tests/HttpTest/ControllersTest/SkinlibControllerTest.php b/tests/HttpTest/ControllersTest/SkinlibControllerTest.php index 7111bfba..edec62db 100644 --- a/tests/HttpTest/ControllersTest/SkinlibControllerTest.php +++ b/tests/HttpTest/ControllersTest/SkinlibControllerTest.php @@ -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() diff --git a/yarn.lock b/yarn.lock index 3b5d3887..ce18a27b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"