itube-specs 0.0.950 → 0.0.952
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/components/card/webcam/index.vue +61 -16
- package/components/card/webcam/languages.vue +6 -1
- package/components/filter/model.vue +2 -2
- package/components/filter/page.vue +2 -2
- package/components/filter/selects.vue +21 -2
- package/components/filter/slider.vue +1 -1
- package/components/filter/videos-chips.vue +4 -10
- package/components/view/videos.vue +0 -16
- package/components/view/webcams.vue +29 -3
- package/composables/use-model-filter-chips.ts +3 -5
- package/i18n/locales/en-US.json +0 -2
- package/package.json +1 -1
- package/types/quick-filter.d.ts +1 -3
- package/utils/filter-selects/__tests__/to-quick-filters.test.ts +3 -3
- package/utils/filter-selects/to-quick-filters.ts +2 -2
- package/utils/webcams/facets.ts +3 -0
- package/utils/webcams/preload-hls.ts +16 -0
|
@@ -9,15 +9,9 @@
|
|
|
9
9
|
на тег. Из нового: цель стримера и превью стрима появляются по ховеру, а на
|
|
10
10
|
тач-устройствах превью включается удержанием тумбы, а уход из кадра его гасит. -->
|
|
11
11
|
<template>
|
|
12
|
-
<article
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@mouseleave="onLeave"
|
|
16
|
-
@touchstart.passive="onTouchStart"
|
|
17
|
-
@touchmove.passive="onTouchMove"
|
|
18
|
-
@touchend="onTouchEnd"
|
|
19
|
-
@touchcancel="onTouchEnd"
|
|
20
|
-
>
|
|
12
|
+
<article class="card-webcam">
|
|
13
|
+
<!-- Превью и цель включает сам постер, а не вся карточка: иначе ховер по имени,
|
|
14
|
+
стране или тегу под тумбой запускал трансляцию, хотя курсор не на кадре. -->
|
|
21
15
|
<a
|
|
22
16
|
ref="posterRef"
|
|
23
17
|
:href="model.url"
|
|
@@ -25,6 +19,12 @@
|
|
|
25
19
|
target="_blank"
|
|
26
20
|
rel="nofollow noopener sponsored"
|
|
27
21
|
@click="onClick"
|
|
22
|
+
@mouseenter="onEnter"
|
|
23
|
+
@mouseleave="onLeave"
|
|
24
|
+
@touchstart.passive="onTouchStart"
|
|
25
|
+
@touchmove.passive="onTouchMove"
|
|
26
|
+
@touchend="onTouchEnd"
|
|
27
|
+
@touchcancel="onTouchEnd"
|
|
28
28
|
>
|
|
29
29
|
<UiImg
|
|
30
30
|
:src="poster"
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
|
|
75
75
|
<span
|
|
76
76
|
v-if="model.goal"
|
|
77
|
-
:class="{'--visible':
|
|
77
|
+
:class="{'--visible': isGoalVisible}"
|
|
78
78
|
class="card-webcam__goal"
|
|
79
79
|
>
|
|
80
80
|
<span class="card-webcam__goal-text _truncate">
|
|
@@ -100,12 +100,15 @@
|
|
|
100
100
|
:niche-path="nichePath"
|
|
101
101
|
/>
|
|
102
102
|
|
|
103
|
+
<!-- Ховер по имени показывает только цель: превью — дело постера. -->
|
|
103
104
|
<a
|
|
104
105
|
:href="model.url"
|
|
105
106
|
class="card-webcam__name _truncate"
|
|
106
107
|
target="_blank"
|
|
107
108
|
rel="nofollow noopener sponsored"
|
|
108
109
|
@click="onClick"
|
|
110
|
+
@mouseenter="onNameEnter"
|
|
111
|
+
@mouseleave="onNameLeave"
|
|
109
112
|
>{{ model.name }}</a>
|
|
110
113
|
|
|
111
114
|
<span
|
|
@@ -174,6 +177,7 @@
|
|
|
174
177
|
<script setup lang="ts">
|
|
175
178
|
import type { IWebcamModel } from '../../../types';
|
|
176
179
|
import { isoCodeToCountryName } from '../../../utils/iso-code-to-country-name';
|
|
180
|
+
import { preloadHls } from '../../../utils/webcams/preload-hls';
|
|
177
181
|
|
|
178
182
|
const props = withDefaults(defineProps<{
|
|
179
183
|
model: IWebcamModel
|
|
@@ -249,10 +253,10 @@ function tagTitle(tag: string): string {
|
|
|
249
253
|
|
|
250
254
|
/**
|
|
251
255
|
* Превью — живой HLS-поток модели (`.m3u8`), а не файл: нативно его понимает
|
|
252
|
-
* только Safari, остальным нужен hls.js.
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
+
* только Safari, остальным нужен hls.js. Библиотеку тянем не с бандлом, а по
|
|
257
|
+
* появлению тумб на экране (`preloadOnVisible` ниже) — к моменту ховера она уже в
|
|
258
|
+
* пути. Поток отпускаем сразу после ухода курсора: тумб на странице много, и
|
|
259
|
+
* держать открытыми несколько трансляций незачем.
|
|
256
260
|
*
|
|
257
261
|
* Воспроизведение запускаем руками, как это делал боевой движок: атрибута
|
|
258
262
|
* `autoplay` для потока, подключённого через MSE, недостаточно.
|
|
@@ -270,7 +274,7 @@ async function startPreview() {
|
|
|
270
274
|
const video = videoRef.value;
|
|
271
275
|
if (!video || !isPreviewActive.value) return;
|
|
272
276
|
|
|
273
|
-
const { default: Hls } = await
|
|
277
|
+
const { default: Hls } = await preloadHls();
|
|
274
278
|
if (!isPreviewActive.value) return;
|
|
275
279
|
|
|
276
280
|
// порядок важен: Chrome на `canPlayType('…mpegurl')` отвечает «maybe», но HLS
|
|
@@ -314,6 +318,24 @@ function onLeave() {
|
|
|
314
318
|
previewSlot.release(previewId.value);
|
|
315
319
|
}
|
|
316
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Цель стримера появляется в двух случаях: при превью (ховер по постеру) и просто
|
|
323
|
+
* при ховере по имени модели — там поток не нужен, а прочитать цель хочется.
|
|
324
|
+
*/
|
|
325
|
+
const isNameHovered = ref(false);
|
|
326
|
+
|
|
327
|
+
const isGoalVisible = computed(() => isPreviewActive.value || isNameHovered.value);
|
|
328
|
+
|
|
329
|
+
function onNameEnter() {
|
|
330
|
+
if (props.touchPreview) return;
|
|
331
|
+
|
|
332
|
+
isNameHovered.value = true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function onNameLeave() {
|
|
336
|
+
isNameHovered.value = false;
|
|
337
|
+
}
|
|
338
|
+
|
|
317
339
|
/**
|
|
318
340
|
* Тач: превью включает удержание тумбы — короткий тап должен открывать модель, а
|
|
319
341
|
* не запускать поток. Движение пальцем считаем скроллом и удержание отменяем.
|
|
@@ -372,9 +394,31 @@ watch(isPreviewActive, (active) => {
|
|
|
372
394
|
});
|
|
373
395
|
|
|
374
396
|
let observer: IntersectionObserver | null = null;
|
|
397
|
+
let preloadObserver: IntersectionObserver | null = null;
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* hls.js не нужен, пока тумбы не доехали до экрана: на длинной странице секция
|
|
401
|
+
* вебкамов может остаться непрочитанной. Первая попавшая в кадр тумба запускает
|
|
402
|
+
* общую загрузку с запасом в 300px — в разделе вебкамов сетка видна сразу, там это
|
|
403
|
+
* срабатывает на первом же кадре.
|
|
404
|
+
*/
|
|
405
|
+
function preloadOnVisible(element: HTMLElement) {
|
|
406
|
+
preloadObserver = new IntersectionObserver((entries) => {
|
|
407
|
+
if (!entries.some(entry => entry.isIntersecting)) return;
|
|
408
|
+
|
|
409
|
+
preloadHls();
|
|
410
|
+
preloadObserver?.disconnect();
|
|
411
|
+
preloadObserver = null;
|
|
412
|
+
}, { rootMargin: '300px' });
|
|
413
|
+
|
|
414
|
+
preloadObserver.observe(element);
|
|
415
|
+
}
|
|
375
416
|
|
|
376
417
|
onMounted(() => {
|
|
377
|
-
if (posterRef.value)
|
|
418
|
+
if (posterRef.value) {
|
|
419
|
+
emit('poster', posterRef.value);
|
|
420
|
+
preloadOnVisible(posterRef.value);
|
|
421
|
+
}
|
|
378
422
|
|
|
379
423
|
if (!props.touchPreview || !posterRef.value) return;
|
|
380
424
|
|
|
@@ -390,6 +434,7 @@ onMounted(() => {
|
|
|
390
434
|
|
|
391
435
|
onBeforeUnmount(() => {
|
|
392
436
|
observer?.disconnect();
|
|
437
|
+
preloadObserver?.disconnect();
|
|
393
438
|
observer = null;
|
|
394
439
|
previewSlot.release(previewId.value);
|
|
395
440
|
stopPreview();
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
<!-- Языки вебкам-модели: флаги-ссылки на фильтр раздела вебкамов.
|
|
2
2
|
|
|
3
|
+
Ссылка идёт в НАШ query-параметр фильтра (`filter_language`), а не в провайдерский
|
|
4
|
+
`mf_*`: состояние фильтров раздела живёт в общих `filter_*`, и по `mf_language`
|
|
5
|
+
раздел открывался вообще без фильтра.
|
|
6
|
+
|
|
3
7
|
Логика повторяет боевой движок: первым идёт язык пользователя, если модель на нём
|
|
4
8
|
говорит. Флагов показываем не больше двух — у иных моделей языков с десяток.
|
|
5
9
|
|
|
@@ -15,7 +19,7 @@
|
|
|
15
19
|
<a
|
|
16
20
|
v-for="item in items"
|
|
17
21
|
:key="`lang-${item.lang}`"
|
|
18
|
-
:href="`${nichePath}/webcams
|
|
22
|
+
:href="`${nichePath}/webcams?${WEBCAM_FILTER_QUERY_PREFIX}language=${item.lang}`"
|
|
19
23
|
:aria-label="item.lang"
|
|
20
24
|
class="card-webcam-languages__link"
|
|
21
25
|
>
|
|
@@ -42,6 +46,7 @@
|
|
|
42
46
|
|
|
43
47
|
<script setup lang="ts">
|
|
44
48
|
import { LANG_TO_FLAG } from '../../../utils/webcams/lang-to-flag';
|
|
49
|
+
import { WEBCAM_FILTER_QUERY_PREFIX } from '../../../utils/webcams/facets';
|
|
45
50
|
|
|
46
51
|
const props = withDefaults(defineProps<{
|
|
47
52
|
/** Языки модели (ISO-коды) */
|
|
@@ -211,7 +211,7 @@ function onChipsClick(item: IFilterChipItem) {
|
|
|
211
211
|
} else {
|
|
212
212
|
delete query[`${item.value}`];
|
|
213
213
|
}
|
|
214
|
-
router.replace({ query });
|
|
214
|
+
router.replace({ path: route.path, query });
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
function onPendingChipsClick(item: IFilterChipItem) {
|
|
@@ -227,7 +227,7 @@ function onClearAllClick() {
|
|
|
227
227
|
.forEach(key => delete query[key]);
|
|
228
228
|
delete query['page'];
|
|
229
229
|
|
|
230
|
-
router.replace({ query });
|
|
230
|
+
router.replace({ path: route.path, query });
|
|
231
231
|
}
|
|
232
232
|
</script>
|
|
233
233
|
|
|
@@ -106,7 +106,7 @@ watch(units, () => {
|
|
|
106
106
|
delete query[`filter_${field}_from`];
|
|
107
107
|
delete query[`filter_${field}_to`];
|
|
108
108
|
}
|
|
109
|
-
router.replace({ query });
|
|
109
|
+
router.replace({ path: route.path, query });
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
const unitItems = computed(() => [
|
|
@@ -140,7 +140,7 @@ function updateFilter(name: string, val: any) {
|
|
|
140
140
|
query[ `filter_${name}` ] = val;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
router.replace({ query });
|
|
143
|
+
router.replace({ path: route.path, query });
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
function isActiveSelect(name: string) {
|
|
@@ -118,6 +118,14 @@ const props = defineProps<{
|
|
|
118
118
|
/** Где живёт состояние: свой query-параметр `filter_<name>` (модели, вебкамы) или общая
|
|
119
119
|
* строка `categories=<группа>_<значение>` (каталог видео, синхронно с попапом фильтра). */
|
|
120
120
|
mode?: 'param' | 'categories'
|
|
121
|
+
/**
|
|
122
|
+
* Своя подпись ВЫБРАННОГО значения на кнопке селекта. Без пропса — обычное поведение
|
|
123
|
+
* (название выбранной опции). Строка — шаблон с `{value}` (например `{value} speaking`),
|
|
124
|
+
* функция получает фильтр и выбранную опцию и может вернуть пусто — тогда подпись
|
|
125
|
+
* стандартная. Нужна там, где выбранное значение читается фразой: в разделе вебкамов
|
|
126
|
+
* язык на кнопке и в чипсе выглядит как «Russian speaking».
|
|
127
|
+
*/
|
|
128
|
+
selectedLabel?: string | ((filter: IModelFilter, option: IModelFilterOptions) => string | undefined)
|
|
121
129
|
}>();
|
|
122
130
|
|
|
123
131
|
const route = useRoute();
|
|
@@ -180,7 +188,7 @@ function onChange(name: string) {
|
|
|
180
188
|
|
|
181
189
|
if (isReset) selectValues.value[name] = getSelectValue(name, query);
|
|
182
190
|
|
|
183
|
-
router.replace({ query });
|
|
191
|
+
router.replace({ path: route.path, query });
|
|
184
192
|
}
|
|
185
193
|
|
|
186
194
|
// Активный слайдер
|
|
@@ -300,7 +308,18 @@ function getSelectLabel(item: IModelFilter) {
|
|
|
300
308
|
const current = readQueryValue(item.name, route.query);
|
|
301
309
|
if (!current) return item.title;
|
|
302
310
|
const opt = item.options.find(o => o.name === current);
|
|
303
|
-
|
|
311
|
+
if (!opt) return item.title;
|
|
312
|
+
|
|
313
|
+
return overrideSelectedLabel(item, opt) || capitalize(opt.title);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Подпись выбранного значения из пропса `selectedLabel`; пусто — берётся стандартная. */
|
|
317
|
+
function overrideSelectedLabel(item: IModelFilter, option: IModelFilterOptions) {
|
|
318
|
+
if (!props.selectedLabel) return '';
|
|
319
|
+
|
|
320
|
+
if (typeof props.selectedLabel === 'function') return props.selectedLabel(item, option) || '';
|
|
321
|
+
|
|
322
|
+
return props.selectedLabel.replace('{value}', capitalize(option.title));
|
|
304
323
|
}
|
|
305
324
|
|
|
306
325
|
// Количество бек считает не для всех опций (у синтетической «all» его нет) — скобки без числа не рисуем
|
|
@@ -26,19 +26,13 @@ const props = defineProps<{
|
|
|
26
26
|
scheme: IFilterScheme[]
|
|
27
27
|
}>();
|
|
28
28
|
|
|
29
|
-
const quickFilterScheme = useAppConfig().quickVideoFilters || [];
|
|
30
|
-
|
|
31
29
|
const findGroup = (name: string) => props.scheme.find(item => item.name.toLowerCase() === name.toLowerCase());
|
|
32
30
|
|
|
33
31
|
/**
|
|
34
|
-
* Название группы для подписи
|
|
35
|
-
*
|
|
36
|
-
* лениво, и до неё остаётся только ключ из query.
|
|
32
|
+
* Название группы для подписи чипсы — как его отдал бек. Схема грузится лениво,
|
|
33
|
+
* и до неё остаётся только ключ из query.
|
|
37
34
|
*/
|
|
38
35
|
function groupLabel(name: string) {
|
|
39
|
-
const quick = quickFilterScheme.find(item => item.name.toLowerCase() === name.toLowerCase());
|
|
40
|
-
if (quick?.titleKey) return t(quick.titleKey);
|
|
41
|
-
|
|
42
36
|
return findGroup(name)?.title || convertString().toCapitalize(name);
|
|
43
37
|
}
|
|
44
38
|
|
|
@@ -113,7 +107,7 @@ function onChipsClick(item: IFilterChipItem) {
|
|
|
113
107
|
|
|
114
108
|
removeChipFromQuery(query, item);
|
|
115
109
|
|
|
116
|
-
router.replace({ query });
|
|
110
|
+
router.replace({ path: route.path, query });
|
|
117
111
|
}
|
|
118
112
|
|
|
119
113
|
/** «Сбросить всё»: снимает фильтры, сортировку не трогает. */
|
|
@@ -123,6 +117,6 @@ function onClearAllClick() {
|
|
|
123
117
|
chipsItems.value.forEach(item => removeChipFromQuery(query, item));
|
|
124
118
|
delete query['page'];
|
|
125
119
|
|
|
126
|
-
router.replace({ query });
|
|
120
|
+
router.replace({ path: route.path, query });
|
|
127
121
|
}
|
|
128
122
|
</script>
|
|
@@ -164,22 +164,6 @@ const modelsRoundCards = computed(() =>
|
|
|
164
164
|
grid-column: 2;
|
|
165
165
|
grid-row: 1;
|
|
166
166
|
}
|
|
167
|
-
|
|
168
|
-
@include to-br(sm) {
|
|
169
|
-
flex-wrap: nowrap;
|
|
170
|
-
max-width: 100vw;
|
|
171
|
-
margin-inline: calc(-1 * var(--container-padding-inline));
|
|
172
|
-
padding-inline: var(--container-padding-inline);
|
|
173
|
-
overflow: auto;
|
|
174
|
-
|
|
175
|
-
&::-webkit-scrollbar {
|
|
176
|
-
display: none;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
.view-videos__quick .filter-selects__wrapper {
|
|
182
|
-
flex-shrink: 0;
|
|
183
167
|
}
|
|
184
168
|
|
|
185
169
|
// до чипсов воздуха больше, чем между сортировкой и фильтрами
|
|
@@ -53,6 +53,7 @@ query-параметрах `filter_*`. Формат провайдера при
|
|
|
53
53
|
<FilterSelects
|
|
54
54
|
v-if="filters.length"
|
|
55
55
|
:filters="filters"
|
|
56
|
+
:selected-label="selectedFilterLabel"
|
|
56
57
|
/>
|
|
57
58
|
</div>
|
|
58
59
|
</div>
|
|
@@ -114,11 +115,11 @@ query-параметрах `filter_*`. Формат провайдера при
|
|
|
114
115
|
</template>
|
|
115
116
|
|
|
116
117
|
<script setup lang="ts">
|
|
117
|
-
import type { IModelCard, IModelFilter, ISwitcherItem, IStripcashModel, IWebcamModelsResponse, PaginatedResponse } from '../../types';
|
|
118
|
+
import type { IModelCard, IModelFilter, IModelFilterOptions, ISwitcherItem, IStripcashModel, IWebcamModelsResponse, PaginatedResponse } from '../../types';
|
|
118
119
|
import { countWebcamFilters } from '../../utils/webcams/count-filters';
|
|
119
120
|
import { normalizeWebcamFacets } from '../../utils/webcams/normalize-facets';
|
|
120
121
|
import { toWebcamFilterPayload } from '../../utils/webcams/to-filter-payload';
|
|
121
|
-
import { WEBCAM_FILTER_ANY } from '../../utils/webcams/facets';
|
|
122
|
+
import { WEBCAM_FILTER_ANY, WEBCAM_LANGUAGE_FACET } from '../../utils/webcams/facets';
|
|
122
123
|
import { sortWebcams } from '../../lib';
|
|
123
124
|
|
|
124
125
|
const props = defineProps<{
|
|
@@ -157,17 +158,42 @@ const items = computed(() => props.data?.items || []);
|
|
|
157
158
|
const allPages = computed(() => props.data?.allPages || 0);
|
|
158
159
|
const filterCount = computed(() => countWebcamFilters(route.query));
|
|
159
160
|
|
|
161
|
+
const currentFacets = computed(() => normalizeWebcamFacets(props.data?.filters, props.data?.total));
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Последние непустые фасеты держим у себя: при нулевом результате провайдер
|
|
165
|
+
* `filters` не присылает, и строка фильтров с чипсами исчезала бы вместе с выдачей —
|
|
166
|
+
* поправить условия было бы нечем, оставался бы только полный сброс.
|
|
167
|
+
*/
|
|
168
|
+
const lastFacets = ref<IModelFilter[]>([]);
|
|
169
|
+
|
|
170
|
+
watchEffect(() => {
|
|
171
|
+
if (currentFacets.value.length) lastFacets.value = currentFacets.value;
|
|
172
|
+
});
|
|
173
|
+
|
|
160
174
|
/**
|
|
161
175
|
* Фасеты провайдера → фильтры слоя. Подписи фильтров переводим по имени (провайдер
|
|
162
176
|
* отдаёт их только по-английски), варианты оставляем как есть — их у него сотни.
|
|
163
177
|
*/
|
|
164
|
-
const filters = computed<IModelFilter[]>(() =>
|
|
178
|
+
const filters = computed<IModelFilter[]>(() => (currentFacets.value.length ? currentFacets.value : lastFacets.value).map(filter => ({
|
|
165
179
|
...filter,
|
|
166
180
|
title: translate(`webcams.filters.${filter.name}`, filter.title),
|
|
167
181
|
options: filter.options.map(option => (option.name === WEBCAM_FILTER_ANY ? { ...option, title: t('all') } : option)),
|
|
168
182
|
group: { ...filter.group, title: translate(`webcams.filter_groups.${filter.group.name}`, filter.group.name) },
|
|
169
183
|
})));
|
|
170
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Выбранный язык на кнопке селекта читается фразой — «Russian speaking», как в макете
|
|
187
|
+
* и как в чипсе облака фильтров. Остальные фильтры стандартные: у них значение и так
|
|
188
|
+
* самодостаточное («Украина», «18-24»). Ключа у сайта может не быть — тогда останется
|
|
189
|
+
* просто название языка.
|
|
190
|
+
*/
|
|
191
|
+
function selectedFilterLabel(filter: IModelFilter, option: IModelFilterOptions) {
|
|
192
|
+
if (filter.name !== WEBCAM_LANGUAGE_FACET || !te('webcams.speaking')) return '';
|
|
193
|
+
|
|
194
|
+
return t('webcams.speaking', { lang: option.title });
|
|
195
|
+
}
|
|
196
|
+
|
|
171
197
|
/** Модалка фильтра слоя ждёт ответ листинга — ей нужны только фасеты. */
|
|
172
198
|
const filterData = computed(() => ({ filters: filters.value }) as unknown as PaginatedResponse<IModelCard>);
|
|
173
199
|
|
|
@@ -3,11 +3,9 @@ import type { LocationQuery } from '#vue-router';
|
|
|
3
3
|
import { getMonth } from '../runtime';
|
|
4
4
|
import { buildChipParts } from '../utils/filter-chips/build-chip-parts';
|
|
5
5
|
import { LANG_TO_FLAG } from '../utils/webcams/lang-to-flag';
|
|
6
|
+
import { WEBCAM_LANGUAGE_FACET } from '../utils/webcams/facets';
|
|
6
7
|
import type { Ref } from 'vue';
|
|
7
8
|
|
|
8
|
-
/** Единственный фильтр-язык у нас в вебкамах: у моделей такого поля нет. */
|
|
9
|
-
const LANGUAGE_FILTER = 'language';
|
|
10
|
-
|
|
11
9
|
export function useFilterChipsItems(
|
|
12
10
|
filters: Ref<IModelFilter[]>,
|
|
13
11
|
route: { query: LocationQuery },
|
|
@@ -95,7 +93,7 @@ export function useFilterChipsItems(
|
|
|
95
93
|
* флага. Если у сайта ключа перевода ещё нет, остаётся название языка.
|
|
96
94
|
*/
|
|
97
95
|
const buildLanguageChip = (valueText: string) => {
|
|
98
|
-
const code = String(route.query[`filter_${
|
|
96
|
+
const code = String(route.query[`filter_${WEBCAM_LANGUAGE_FACET}`] || '').toLowerCase();
|
|
99
97
|
|
|
100
98
|
return {
|
|
101
99
|
title: t('webcams.speaking', { lang: valueText }) || valueText,
|
|
@@ -110,7 +108,7 @@ export function useFilterChipsItems(
|
|
|
110
108
|
const valueText = getValueText(item);
|
|
111
109
|
if (!valueText) return null;
|
|
112
110
|
return {
|
|
113
|
-
...(filter.name ===
|
|
111
|
+
...(filter.name === WEBCAM_LANGUAGE_FACET
|
|
114
112
|
? buildLanguageChip(valueText)
|
|
115
113
|
: buildChipParts(filter.title, valueText)),
|
|
116
114
|
value: item.map(
|
package/i18n/locales/en-US.json
CHANGED
|
@@ -76,8 +76,6 @@
|
|
|
76
76
|
"tatoos": "Tattoos",
|
|
77
77
|
"orientation": "Orientation"
|
|
78
78
|
},
|
|
79
|
-
"quick_filter_nationality": "Nationality",
|
|
80
|
-
"quick_filter_participants": "Participants",
|
|
81
79
|
"language_and_region": "Language & Region",
|
|
82
80
|
"all_models": "All models",
|
|
83
81
|
"user_playlists": "User playlists",
|
package/package.json
CHANGED
package/types/quick-filter.d.ts
CHANGED
|
@@ -12,11 +12,9 @@ export interface IQuickFilterGroup {
|
|
|
12
12
|
categories: IQuickFilterCategory[]
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/** Описание квик-фильтра каталога видео: имя группы категорий
|
|
16
|
-
* группы с бека не подходит для короткой кнопки, свой i18n-ключ заголовка. */
|
|
15
|
+
/** Описание квик-фильтра каталога видео: имя группы категорий с бека. */
|
|
17
16
|
export interface IQuickFilterScheme {
|
|
18
17
|
name: string
|
|
19
|
-
titleKey?: string
|
|
20
18
|
/** Убрать кнопку из строки на узких экранах (до `sm`) — фильтр остаётся в попапе. */
|
|
21
19
|
hideOnMobile?: boolean
|
|
22
20
|
}
|
|
@@ -45,10 +45,10 @@ describe('toQuickFilters', () => {
|
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
it('заголовок берёт
|
|
49
|
-
const [filter] = toQuickFilters(groups, [{ name: 'PPL Involved'
|
|
48
|
+
it('заголовок берёт с бека, а не из схемы', () => {
|
|
49
|
+
const [filter] = toQuickFilters(groups, [{ name: 'PPL Involved' }], translate);
|
|
50
50
|
|
|
51
|
-
expect(filter?.title).toBe('
|
|
51
|
+
expect(filter?.title).toBe('PPL Involved');
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
it('разворачивает hideOnMobile схемы в showOnMobile по каждому фильтру', () => {
|
|
@@ -13,7 +13,7 @@ import type { IModelFilter, IQuickFilterGroup, IQuickFilterScheme } from '../../
|
|
|
13
13
|
*
|
|
14
14
|
* @param groups - группы категорий с бека
|
|
15
15
|
* @param scheme - какие группы показывать и в каком порядке
|
|
16
|
-
* @param translate - `t` из `useI18n` для
|
|
16
|
+
* @param translate - `t` из `useI18n` для опции «all»
|
|
17
17
|
*/
|
|
18
18
|
export function toQuickFilters(
|
|
19
19
|
groups: IQuickFilterGroup[],
|
|
@@ -28,7 +28,7 @@ export function toQuickFilters(
|
|
|
28
28
|
return {
|
|
29
29
|
kind: 'select',
|
|
30
30
|
name: group.name.toLowerCase(),
|
|
31
|
-
title:
|
|
31
|
+
title: group.title,
|
|
32
32
|
icon: '',
|
|
33
33
|
show_on_form: true,
|
|
34
34
|
group: { name: 'quick', title: 'quick', description: '', order: 0 },
|
package/utils/webcams/facets.ts
CHANGED
|
@@ -24,6 +24,9 @@ export const WEBCAM_QUICK_FACETS = ['age', 'language', 'country', 'specifics'];
|
|
|
24
24
|
/** Значение селекта «любой» — слой по нему удаляет параметр из query. */
|
|
25
25
|
export const WEBCAM_FILTER_ANY = 'all';
|
|
26
26
|
|
|
27
|
+
/** Имя языкового фасета: у него подпись выбранного значения читается фразой («Russian speaking»). */
|
|
28
|
+
export const WEBCAM_LANGUAGE_FACET = 'language';
|
|
29
|
+
|
|
27
30
|
/** Группы фильтров: строка фильтров и всё остальное. */
|
|
28
31
|
export const WEBCAM_FILTER_GROUPS = {
|
|
29
32
|
quick: { name: 'quick', order: 0 },
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Один общий импорт hls.js на страницу.
|
|
3
|
+
*
|
|
4
|
+
* Библиотека весит больше, чем весь вебкам-блок, и на большинстве показов не нужна
|
|
5
|
+
* вовсе, поэтому её не тянут ни с бандлом страницы, ни при монтировании тумб —
|
|
6
|
+
* только когда тумбы реально появились на экране (см. `CardWebcam`). Промис
|
|
7
|
+
* кешируется: сколько бы тумб ни попало в кадр, запрос уйдёт один, а превью по
|
|
8
|
+
* ховеру дождётся уже начатой загрузки вместо своей.
|
|
9
|
+
*/
|
|
10
|
+
let hlsModule: Promise<typeof import('hls.js')> | null = null;
|
|
11
|
+
|
|
12
|
+
export function preloadHls() {
|
|
13
|
+
if (!hlsModule) hlsModule = import('hls.js');
|
|
14
|
+
|
|
15
|
+
return hlsModule;
|
|
16
|
+
}
|