itube-specs 0.0.938 → 0.0.940
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/scss/themes/dark/_theme.scss +1 -0
- package/assets/scss/themes/light/_theme.scss +1 -0
- package/components/filter/chips.vue +33 -1
- package/components/filter/model.vue +7 -3
- package/components/filter/selects.vue +3 -1
- package/components/section/nav-links.vue +5 -1
- package/components/view/webcams.vue +6 -6
- package/composables/__tests__/use-model-filter-chips.test.ts +36 -0
- package/composables/fetch/use-fetch-playlists-by-id.ts +2 -1
- package/composables/fetch/use-fetch-playlists-get-videos-by-id.ts +2 -1
- package/composables/use-filter-pending.ts +4 -2
- package/composables/use-model-filter-chips.ts +24 -2
- package/package.json +1 -1
- package/types/filter-chip-item.d.ts +4 -0
- package/utils/ui-pagination/__tests__/get-pagination-link.test.ts +8 -3
- package/utils/ui-pagination/get-pagination-link.ts +11 -3
|
@@ -713,6 +713,7 @@ $filter-chips-bg--hover: $color-zinc-800-60;
|
|
|
713
713
|
$filter-chips-close: $color-zinc-500;
|
|
714
714
|
$filter-chips-close--hover: $color-white;
|
|
715
715
|
$filter-chips-label: $color-zinc-500;
|
|
716
|
+
$filter-chips-flag-border: $color-white-20;
|
|
716
717
|
$filter-chips-row-clear-text: $color-zinc-500;
|
|
717
718
|
$filter-chips-row-clear-text--hover: $color-white;
|
|
718
719
|
|
|
@@ -762,6 +762,7 @@ $filter-chips-bg--hover: $color-gray-300;
|
|
|
762
762
|
$filter-chips-close: $color-gray-500;
|
|
763
763
|
$filter-chips-close--hover: $color-gray-950;
|
|
764
764
|
$filter-chips-label: $color-gray-500;
|
|
765
|
+
$filter-chips-flag-border: $color-gray-300;
|
|
765
766
|
$filter-chips-row-clear-text: $color-gray-500;
|
|
766
767
|
$filter-chips-row-clear-text--hover: $color-gray-950;
|
|
767
768
|
|
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
<!-- Чипса выбранного фильтра: приглушённое название фильтра, значение и сброс по
|
|
1
|
+
<!-- Чипса выбранного фильтра: приглушённое название фильтра, значение и сброс по клику.
|
|
2
|
+
|
|
3
|
+
У языковой чипсы вебкамов вместо названия фильтра идёт флаг страны языка, а подпись
|
|
4
|
+
уже собрана переводом («Russian speaking») — поэтому у неё гасится капитализация слов.
|
|
5
|
+
Файла флага для языка может не быть (Китай, Корея) — тогда чипса остаётся без него. -->
|
|
2
6
|
<template>
|
|
3
7
|
<button
|
|
8
|
+
:class="{'--exact-case': item.flag}"
|
|
4
9
|
class="filter-chips"
|
|
5
10
|
type="button"
|
|
6
11
|
@click="emit('click')"
|
|
7
12
|
>
|
|
13
|
+
<img
|
|
14
|
+
v-if="item.flag && !isFlagBroken"
|
|
15
|
+
:src="`/icons/flags/${item.flag}.svg`"
|
|
16
|
+
:alt="item.flag"
|
|
17
|
+
class="filter-chips__flag"
|
|
18
|
+
width="16"
|
|
19
|
+
height="16"
|
|
20
|
+
loading="lazy"
|
|
21
|
+
@error="isFlagBroken = true"
|
|
22
|
+
>
|
|
8
23
|
<span class="filter-chips__text">
|
|
9
24
|
<span
|
|
10
25
|
v-if="item.label && !withoutLabel"
|
|
@@ -31,6 +46,8 @@ defineProps<{
|
|
|
31
46
|
const emit = defineEmits<{
|
|
32
47
|
(eventName: 'click'): void
|
|
33
48
|
}>();
|
|
49
|
+
|
|
50
|
+
const isFlagBroken = ref(false);
|
|
34
51
|
</script>
|
|
35
52
|
|
|
36
53
|
<style lang="scss">
|
|
@@ -58,6 +75,21 @@ const emit = defineEmits<{
|
|
|
58
75
|
}
|
|
59
76
|
}
|
|
60
77
|
|
|
78
|
+
.filter-chips.--exact-case {
|
|
79
|
+
text-transform: none;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// «бабл» как у флагов в тумбе модели, только мельче: круг со срезанным левым нижним углом
|
|
83
|
+
.filter-chips__flag {
|
|
84
|
+
flex-shrink: 0;
|
|
85
|
+
width: 16px;
|
|
86
|
+
height: 16px;
|
|
87
|
+
object-fit: cover;
|
|
88
|
+
border: 1px solid $filter-chips-flag-border;
|
|
89
|
+
border-radius: 50%;
|
|
90
|
+
border-end-start-radius: 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
61
93
|
// подпись и значение читаются одной фразой, поэтому не отдельный флекс-элемент
|
|
62
94
|
.filter-chips__label {
|
|
63
95
|
padding-right: 4px;
|
|
@@ -78,7 +78,7 @@ const props = defineProps<{
|
|
|
78
78
|
|
|
79
79
|
const model = defineModel<boolean>();
|
|
80
80
|
|
|
81
|
-
const { t, locale } = useI18n();
|
|
81
|
+
const { t, te, locale } = useI18n();
|
|
82
82
|
const route = useRoute();
|
|
83
83
|
const router = useRouter();
|
|
84
84
|
|
|
@@ -108,8 +108,12 @@ const pendingRouteLike = reactive({
|
|
|
108
108
|
},
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
/** Пустая строка вместо имени ключа: у сайта перевода может не быть, и тогда
|
|
112
|
+
* подпись чипсы соберётся без него (см. `useFilterChipsItems`). */
|
|
113
|
+
const translate = (key: string, params?: Record<string, unknown>) => (te(key) ? t(key, params || {}) : '');
|
|
114
|
+
|
|
115
|
+
const { chipsItems } = useFilterChipsItems(filters, route, translate, locale);
|
|
116
|
+
const { chipsItems: pendingChipsItems } = useFilterChipsItems(filters, pendingRouteLike, translate, locale);
|
|
113
117
|
|
|
114
118
|
const quickFilters = computed(() =>
|
|
115
119
|
filters.value
|
|
@@ -360,7 +360,9 @@ function getOptionLabel(option: IModelFilterOptions) {
|
|
|
360
360
|
text-transform: none;
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
-
.
|
|
363
|
+
// .ui-btn в селекторе не для красоты: без него специфичность равна .ui-btn.--secondary,
|
|
364
|
+
// который правит те же свойства, и кто победит — решает порядок блоков в бандле
|
|
365
|
+
.ui-btn.filter-selects__select-button.--active-select {
|
|
364
366
|
padding: 6px 14px;
|
|
365
367
|
border: 1px solid $filters-main-select-border--active;
|
|
366
368
|
color: $filters-main-select-text--active;
|
|
@@ -41,12 +41,16 @@ function linkTo(item: INavLinkItem, index: number) {
|
|
|
41
41
|
return { path: route.path, query: query };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
// путь всегда явный: локацию без него роутер разрешает сам и у нишевых разделов
|
|
45
|
+
// (они живут на алиасах записей) терял префикс — а из `resolve` собирается `href`,
|
|
46
|
+
// так что промах уезжал в разметку ссылки
|
|
47
|
+
if (!item.key) return { path: route.path, query: { ...route.query } };
|
|
45
48
|
|
|
46
49
|
const oldQuery = { ...route.query };
|
|
47
50
|
delete oldQuery['page'];
|
|
48
51
|
|
|
49
52
|
return {
|
|
53
|
+
path: route.path,
|
|
50
54
|
query: {
|
|
51
55
|
...oldQuery,
|
|
52
56
|
[item.key]: item.value
|
|
@@ -213,10 +213,10 @@ const sections = computed<ISwitcherItem[]>(() => [
|
|
|
213
213
|
]);
|
|
214
214
|
|
|
215
215
|
/**
|
|
216
|
-
* Вход в раздел:
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
216
|
+
* Вход в раздел: сразу показываем моделей, которые говорят на языке пользователя.
|
|
217
|
+
* Только при первом заходе за сессию — иначе снятый пользователем фильтр
|
|
218
|
+
* возвращался бы сам, — и только если такой язык у провайдера в выдаче есть
|
|
219
|
+
* (иначе получили бы пустую страницу).
|
|
220
220
|
*/
|
|
221
221
|
const isLanguageFilterApplied = useState('webcams-language-filter', () => false);
|
|
222
222
|
|
|
@@ -225,12 +225,12 @@ onMounted(() => {
|
|
|
225
225
|
isLanguageFilterApplied.value = true;
|
|
226
226
|
|
|
227
227
|
const code = locale.value.slice(0, 2).toLowerCase();
|
|
228
|
-
if (
|
|
228
|
+
if (filterCount.value > 0) return;
|
|
229
229
|
|
|
230
230
|
const languageFilter = filters.value.find(item => item.name === 'language');
|
|
231
231
|
if (!languageFilter?.options.some(option => option.name === code)) return;
|
|
232
232
|
|
|
233
|
-
router.replace({ query: { ...route.query, filter_language: code } });
|
|
233
|
+
router.replace({ path: route.path, query: { ...route.query, filter_language: code } });
|
|
234
234
|
});
|
|
235
235
|
|
|
236
236
|
function translate(key: string, fallback: string) {
|
|
@@ -36,6 +36,42 @@ describe('useFilterChipsItems', () => {
|
|
|
36
36
|
expect(chipsItems.value[0].value).toEqual(['filter_hair_color']);
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
+
it('языковая чипса: флаг страны языка и подпись переводом, без названия фильтра', () => {
|
|
40
|
+
const filters = ref([{
|
|
41
|
+
name: 'language',
|
|
42
|
+
title: 'Language',
|
|
43
|
+
options: [
|
|
44
|
+
{ name: 'es', title: 'Spanish' },
|
|
45
|
+
{ name: 'ru', title: 'Russian' },
|
|
46
|
+
],
|
|
47
|
+
}]);
|
|
48
|
+
const route = { query: { filter_language: 'es' } };
|
|
49
|
+
const translate = (key: string, params?: Record<string, unknown>) => `${params?.lang} speaking (${key})`;
|
|
50
|
+
const { chipsItems } = useFilterChipsItems(filters, route, translate, ref('en'));
|
|
51
|
+
|
|
52
|
+
expect(chipsItems.value[0]).toEqual({
|
|
53
|
+
title: 'Spanish speaking (webcams.speaking)',
|
|
54
|
+
flag: 'es',
|
|
55
|
+
value: ['filter_language'],
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('языковая чипса: язык без своей страны берёт флаг по мапе, без перевода — только язык', () => {
|
|
60
|
+
const filters = ref([{
|
|
61
|
+
name: 'language',
|
|
62
|
+
title: 'Language',
|
|
63
|
+
options: [{ name: 'ja', title: 'Japanese' }],
|
|
64
|
+
}]);
|
|
65
|
+
const route = { query: { filter_language: 'ja' } };
|
|
66
|
+
const { chipsItems } = useFilterChipsItems(filters, route, () => '', ref('en'));
|
|
67
|
+
|
|
68
|
+
expect(chipsItems.value[0]).toEqual({
|
|
69
|
+
title: 'Japanese',
|
|
70
|
+
flag: 'jp',
|
|
71
|
+
value: ['filter_language'],
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
39
75
|
it('группирует from/to фильтры', () => {
|
|
40
76
|
const filters = ref([{
|
|
41
77
|
name: 'age',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useRoute } from 'vue-router';
|
|
2
|
-
import { AsyncData
|
|
2
|
+
import { AsyncData } from '../../runtime';
|
|
3
|
+
import type { Language } from '../../runtime';
|
|
3
4
|
import type { IPlaylistData } from '../../types';
|
|
4
5
|
|
|
5
6
|
export const useFetchPlaylistsById = async (apiMethod: (...args: any[]) => Promise<IPlaylistData>) => {
|
|
@@ -64,8 +64,10 @@ export function useFilterPending(options: {
|
|
|
64
64
|
options.open.value = false;
|
|
65
65
|
const query = options.encode(values.value, route.query);
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// путь явно: локация без него разрешается роутером, и у нишевых разделов
|
|
68
|
+
// (алиасы записей) префикс ниши пропадал
|
|
69
|
+
if (options.navigate === 'replace') router.replace({ path: route.path, query });
|
|
70
|
+
else router.push({ path: route.path, query });
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
return { values, count, isFiltered, set, remove, replace, reset, apply };
|
|
@@ -2,12 +2,16 @@ import type { IFilterChipItem, IModelFilter, IModelFilterOptions } from '../type
|
|
|
2
2
|
import type { LocationQuery } from '#vue-router';
|
|
3
3
|
import { getMonth } from '../runtime';
|
|
4
4
|
import { buildChipParts } from '../utils/filter-chips/build-chip-parts';
|
|
5
|
+
import { LANG_TO_FLAG } from '../utils/webcams/lang-to-flag';
|
|
5
6
|
import type { Ref } from 'vue';
|
|
6
7
|
|
|
8
|
+
/** Единственный фильтр-язык у нас в вебкамах: у моделей такого поля нет. */
|
|
9
|
+
const LANGUAGE_FILTER = 'language';
|
|
10
|
+
|
|
7
11
|
export function useFilterChipsItems(
|
|
8
12
|
filters: Ref<IModelFilter[]>,
|
|
9
13
|
route: { query: LocationQuery },
|
|
10
|
-
t: (key: string) => string,
|
|
14
|
+
t: (key: string, params?: Record<string, unknown>) => string,
|
|
11
15
|
locale: Ref<string>
|
|
12
16
|
) {
|
|
13
17
|
const chipsItems = computed<IFilterChipItem[]>(() => {
|
|
@@ -83,6 +87,22 @@ export function useFilterChipsItems(
|
|
|
83
87
|
const getValueText = (items: string[]) =>
|
|
84
88
|
[...new Set(items.map(getValue).filter(v => v !== undefined && v !== null && v !== ''))].join(' - ');
|
|
85
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Язык — не просто значение фильтра, а свойство модели: в облаке он читается
|
|
92
|
+
* как «Russian speaking» и с флагом страны языка, без приглушённой подписи
|
|
93
|
+
* фильтра (по макету раздела вебкамов). Флаг лежит файлом по коду СТРАНЫ, а
|
|
94
|
+
* фильтр приходит языком — переводим по мапе; нет файла — чипса просто без
|
|
95
|
+
* флага. Если у сайта ключа перевода ещё нет, остаётся название языка.
|
|
96
|
+
*/
|
|
97
|
+
const buildLanguageChip = (valueText: string) => {
|
|
98
|
+
const code = String(route.query[`filter_${LANGUAGE_FILTER}`] || '').toLowerCase();
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
title: t('webcams.speaking', { lang: valueText }) || valueText,
|
|
102
|
+
flag: LANG_TO_FLAG[code] || code,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
86
106
|
return groups
|
|
87
107
|
.map(item => {
|
|
88
108
|
const filter = findFilter(item);
|
|
@@ -90,7 +110,9 @@ export function useFilterChipsItems(
|
|
|
90
110
|
const valueText = getValueText(item);
|
|
91
111
|
if (!valueText) return null;
|
|
92
112
|
return {
|
|
93
|
-
...
|
|
113
|
+
...(filter.name === LANGUAGE_FILTER
|
|
114
|
+
? buildLanguageChip(valueText)
|
|
115
|
+
: buildChipParts(filter.title, valueText)),
|
|
94
116
|
value: item.map(
|
|
95
117
|
sub => `filter_${sub.replace(/ /g, '_')}`
|
|
96
118
|
),
|
package/package.json
CHANGED
|
@@ -9,4 +9,8 @@ export interface IFilterChipItem {
|
|
|
9
9
|
value?: string | string[] | number
|
|
10
10
|
/** Тип фильтра, к которому относится чипса. */
|
|
11
11
|
key?: string
|
|
12
|
+
/** Код страны для флага (`/icons/flags/<код>.svg`) — у языковой чипсы вебкамов.
|
|
13
|
+
* У такой чипсы подпись собрана переводом («Russian speaking»), поэтому
|
|
14
|
+
* капитализация слов к ней не применяется. */
|
|
15
|
+
flag?: string
|
|
12
16
|
}
|
|
@@ -7,9 +7,14 @@ describe('getPaginationLink', () => {
|
|
|
7
7
|
expect(result).toEqual({ path: '/videos', query: { sort: 'new' } });
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
-
it('для прочих страниц сохраняет query и проставляет номер
|
|
10
|
+
it('для прочих страниц сохраняет query и проставляет номер', () => {
|
|
11
11
|
const result = getPaginationLink(5, 'page', '/videos', { sort: 'new' });
|
|
12
|
-
expect(result).toEqual({ query: { sort: 'new', page: 5 } });
|
|
12
|
+
expect(result).toEqual({ path: '/videos', query: { sort: 'new', page: 5 } });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('путь есть у любой страницы: относительную локацию роутер разрешал бы сам', () => {
|
|
16
|
+
const result = getPaginationLink(3, 'page', '/gay/webcams', { filter_language: 'ru' });
|
|
17
|
+
expect(result).toEqual({ path: '/gay/webcams', query: { filter_language: 'ru', page: 3 } });
|
|
13
18
|
});
|
|
14
19
|
|
|
15
20
|
it('зажимает значение < 1 к первой странице', () => {
|
|
@@ -19,6 +24,6 @@ describe('getPaginationLink', () => {
|
|
|
19
24
|
|
|
20
25
|
it('перезаписывает существующий префикс', () => {
|
|
21
26
|
const result = getPaginationLink(7, 'page', '/videos', { page: '2', q: 'x' });
|
|
22
|
-
expect(result).toEqual({ query: { page: 7, q: 'x' } });
|
|
27
|
+
expect(result).toEqual({ path: '/videos', query: { page: 7, q: 'x' } });
|
|
23
28
|
});
|
|
24
29
|
});
|
|
@@ -3,10 +3,17 @@ type QueryRecord = Record<string, QueryParam>;
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Строит объект ссылки для страницы пагинации: для первой страницы убирает параметр `prefix`
|
|
6
|
-
* из query
|
|
6
|
+
* из query, для остальных — проставляет номер. Значение < 1 зажимается к 1.
|
|
7
|
+
*
|
|
8
|
+
* Путь проставляем ВСЕГДА, даже когда он равен текущему. Локация без `path` — относительная,
|
|
9
|
+
* и её разрешает роутер: у нишевых разделов (они живут на алиасах записей) так пропадал
|
|
10
|
+
* префикс ниши, а `href` у ссылки вообще собирается из результата `resolve` — то есть любой
|
|
11
|
+
* промах разрешения уезжает в разметку. Клик мышью при этом работает (навигация идёт по
|
|
12
|
+
* объекту), а «открыть в новой вкладке» — уже по `href`, и открывалось не то.
|
|
13
|
+
*
|
|
7
14
|
* @param value - номер страницы
|
|
8
15
|
* @param prefix - имя query-параметра страницы (напр. `page`)
|
|
9
|
-
* @param path - текущий путь
|
|
16
|
+
* @param path - текущий путь
|
|
10
17
|
* @param query - текущий query
|
|
11
18
|
*/
|
|
12
19
|
export function getPaginationLink(
|
|
@@ -14,7 +21,7 @@ export function getPaginationLink(
|
|
|
14
21
|
prefix: string,
|
|
15
22
|
path: string,
|
|
16
23
|
query: QueryRecord,
|
|
17
|
-
): { path
|
|
24
|
+
): { path: string; query: QueryRecord } {
|
|
18
25
|
const safeValue = Math.max(1, Number(value));
|
|
19
26
|
|
|
20
27
|
if (safeValue === 1) {
|
|
@@ -26,6 +33,7 @@ export function getPaginationLink(
|
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
return {
|
|
36
|
+
path,
|
|
29
37
|
query: {
|
|
30
38
|
...query,
|
|
31
39
|
[prefix]: safeValue,
|