itube-specs 0.0.1052 → 0.0.1054
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/SITE_UPDATES.md +30 -0
- package/app.config.ts +2 -0
- package/assets/scss/themes/dark/_theme.scss +5 -0
- package/assets/scss/themes/light/_theme.scss +5 -0
- package/components/card/channel.vue +3 -3
- package/components/card/video/index.vue +8 -8
- package/components/player/core.vue +1 -0
- package/components/player/interface.vue +117 -13
- package/components/view/model-videos.vue +2 -4
- package/components/view/video.vue +28 -10
- package/components/view/videos.vue +2 -4
- package/composables/page/use-category-page.ts +10 -4
- package/composables/page/use-channel-page.ts +8 -3
- package/composables/page/use-model-page.ts +8 -3
- package/package.json +1 -1
- package/pages/categories/[slug].vue +2 -4
- package/pages/channels/[channel].vue +2 -4
- package/pages/search/[slug]/index.vue +2 -4
- package/runtime/index.ts +1 -0
- package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +34 -0
- package/runtime/utils/cleaners/clean-video-data.ts +27 -2
- package/runtime/utils/converters/convert-tag-group-item-to-chips.ts +14 -0
- package/server/api/videos/get-video-by-id/[id].get.ts +2 -1
- package/server/api/videos/get-video-by-url/[url].get.ts +2 -1
- package/server/utils/video-tag-groups-mock.ts +33 -0
- package/types/player-site-hooks.d.ts +2 -0
- package/types/raw/raw-video-data.d.ts +3 -2
- package/types/video-data.d.ts +24 -0
package/SITE_UPDATES.md
CHANGED
|
@@ -11,6 +11,36 @@
|
|
|
11
11
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
12
12
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
13
13
|
|
|
14
|
+
## 0.0.1054 — 2026-10-28
|
|
15
|
+
|
|
16
|
+
- **Сайтам со своей темой (hdsex-remaster):** в `_theme.scss` добавить токены тегов в раскрытом описании видео —
|
|
17
|
+
`$video-info-tags-title: $color-zinc-500; $video-info-tag-text: $color-zinc-300; $video-info-tag-text--hover:
|
|
18
|
+
$color-white; $video-info-tag-bg: $color-zinc-800; $video-info-tag-bg--hover: $color-zinc-700;` (светлая тема слоя —
|
|
19
|
+
серая шкала). Не сделать — сборка упадёт на неизвестном токене.
|
|
20
|
+
- **Всем — к сведению:** описание видео (`PlayerInterface`) по новому макету: в раскрытом виде под текстом — группы
|
|
21
|
+
(`IVideoData.tagGroups`), кнопка раскрытия стоит в нижней строке блока (свёрнуто — на второй строке описания,
|
|
22
|
+
раскрыто — на последней строке групп). Ручка видео переходит на v2: в `tags` — узлы `IVideoTagGroup`
|
|
23
|
+
(`type: 'tag' | 'category' | 'search'`, `items: IVideoTagGroupItem[]`), ссылка элемента строится по типу узла
|
|
24
|
+
(`convertTagGroupItemToChips`); строка над плеером — склейка элементов всех групп. Пока бекенд v2 не готов, BFF ходит
|
|
25
|
+
в v1 и отдаёт одну статичную заглушку групп из категорий сайта (и lorem-описание, если своего нет) —
|
|
26
|
+
`server/utils/video-tag-groups-mock.ts`, уберём, когда группы начнут приходить. Делать ничего не надо.
|
|
27
|
+
- **Всем — к сведению:** кружок канала на тумбе видео и в листинге каналов — 44px (было 32). Делать ничего не надо.
|
|
28
|
+
|
|
29
|
+
## 0.0.1053 — 2026-10-26
|
|
30
|
+
|
|
31
|
+
- **Всем — к сведению:** нативка в конце сеток листингов и похожих (`AdvNativeBottom` в `view/video`, `view/videos`,
|
|
32
|
+
`view/model-videos`, страницах категории, канала, поиска) больше не ленивая. Контейнер приезжает в серверной
|
|
33
|
+
разметке, и сеть заполняет его до гидрации, а ленивая гидрация пересобирала уже заполненный iframe — креатив
|
|
34
|
+
терялся, слот оставался пустым (найдено на hdsex-remaster). Делать ничего не надо.
|
|
35
|
+
- **Сайтам со своими сортировками на страницах на page-composables:** у `useCategoryPage`, `useChannelPage`,
|
|
36
|
+
`useModelPage` новая опция `sortOptions` (по умолчанию `sortItemsDefault`) — по ней ключи меты и проверка `?sort`.
|
|
37
|
+
Без неё свои значения `?sort` такой страницы — 404. Образец — коммит hdsex-remaster с этим изменением.
|
|
38
|
+
- **Сайтам с частотным капом преролла (hdsex-remaster):** в `usePlayerSiteHooks()` новый хук `onAdStart()` — старт
|
|
39
|
+
рекламы в плеере (например, поставить куку капа). Остальным — ничего.
|
|
40
|
+
- **Сайтам с боковой колонкой моделей у плеера (hdsex-remaster):** `appConfig.videoPage.hideAsideModelChips: true` —
|
|
41
|
+
с 1024px не дублировать чипсой над плеером модель, у которой есть блок в колонке. По умолчанию `false`, ничего не
|
|
42
|
+
меняется.
|
|
43
|
+
|
|
14
44
|
## 0.0.1052 — 2026-10-22
|
|
15
45
|
|
|
16
46
|
- **Всем — к сведению:** мусорные адреса листингов теперь 404. `?page` не натуральное число (`0`, `-1`, `abc`, `01`) —
|
package/app.config.ts
CHANGED
|
@@ -39,6 +39,8 @@ export default defineAppConfig({
|
|
|
39
39
|
webcamsBlock: false,
|
|
40
40
|
/** Сколько моделей из хвоста списка уходит во врезки похожих, когда блок Live Cams включён. */
|
|
41
41
|
webcamInserts: 8,
|
|
42
|
+
/** С 1024px прятать чипсы моделей над плеером, у которых есть блок в боковой колонке (не дублировать ссылку). */
|
|
43
|
+
hideAsideModelChips: false,
|
|
42
44
|
/** Мета ролика как на старых движках: description — до трёх фраз перехода (недостающие — из тегов), без них —
|
|
43
45
|
* категории, склейка ключами `meta.videos-slug.tags_and` / `tags_tail`; в title доступен `{thirdText}` — название
|
|
44
46
|
* в Title Case. Выключено — description из описания ролика. */
|
|
@@ -241,6 +241,11 @@ $video-info-item-text: $color-zinc-500-70;
|
|
|
241
241
|
$video-info-handler-text: $color-zinc-500;
|
|
242
242
|
$video-info-handler-text--hover: $color-white;
|
|
243
243
|
$video-info-description-text: $color-zinc-400;
|
|
244
|
+
$video-info-tags-title: $color-zinc-500;
|
|
245
|
+
$video-info-tag-text: $color-zinc-300;
|
|
246
|
+
$video-info-tag-text--hover: $color-white;
|
|
247
|
+
$video-info-tag-bg: $color-zinc-800;
|
|
248
|
+
$video-info-tag-bg--hover: $color-zinc-700;
|
|
244
249
|
|
|
245
250
|
// ─── Video interface ───
|
|
246
251
|
$video-interface-button-bg: $color-zinc-800;
|
|
@@ -297,6 +297,11 @@ $video-info-item-text: $color-gray-400;
|
|
|
297
297
|
$video-info-handler-text: $color-gray-400;
|
|
298
298
|
$video-info-handler-text--hover: $color-gray-900;
|
|
299
299
|
$video-info-description-text: $color-gray-700;
|
|
300
|
+
$video-info-tags-title: $color-gray-500;
|
|
301
|
+
$video-info-tag-text: $color-gray-700;
|
|
302
|
+
$video-info-tag-text--hover: $color-gray-950;
|
|
303
|
+
$video-info-tag-bg: $color-gray-100;
|
|
304
|
+
$video-info-tag-bg--hover: $color-gray-200;
|
|
300
305
|
|
|
301
306
|
// ─── Video interface ───
|
|
302
307
|
$video-interface-button-bg: $color-slate-750;
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
:src="card.avatarUrl"
|
|
31
31
|
:alt="card.name"
|
|
32
32
|
class="card-channel__logo _from-xs"
|
|
33
|
-
width="
|
|
34
|
-
height="
|
|
33
|
+
width="44"
|
|
34
|
+
height="44"
|
|
35
35
|
>
|
|
36
36
|
<span
|
|
37
37
|
v-else
|
|
@@ -157,7 +157,7 @@ const infoItems = computed<InfoItem[]>(() => [
|
|
|
157
157
|
.card-channel__logo.--placeholder {
|
|
158
158
|
@include font-sm;
|
|
159
159
|
|
|
160
|
-
$size:
|
|
160
|
+
$size: 44px;
|
|
161
161
|
|
|
162
162
|
width: $size;
|
|
163
163
|
height: $size;
|
|
@@ -79,9 +79,9 @@
|
|
|
79
79
|
:src="currentAvatar"
|
|
80
80
|
:alt="card.channelName"
|
|
81
81
|
class="card-video__channel-logo"
|
|
82
|
-
width="
|
|
83
|
-
height="
|
|
84
|
-
sizes="
|
|
82
|
+
width="44"
|
|
83
|
+
height="44"
|
|
84
|
+
sizes="44px"
|
|
85
85
|
@error="onAvatarError"
|
|
86
86
|
/>
|
|
87
87
|
<span
|
|
@@ -349,13 +349,13 @@ $dropdown-column: 18px;
|
|
|
349
349
|
|
|
350
350
|
@include from-cq($layout-breakpoint) {
|
|
351
351
|
column-gap: 12px;
|
|
352
|
-
grid-template-columns:
|
|
352
|
+
grid-template-columns: 44px minmax(0, 1fr) $dropdown-column;
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
355
|
|
|
356
356
|
.--avatar .card-video__wrapper {
|
|
357
357
|
column-gap: 12px;
|
|
358
|
-
grid-template-columns:
|
|
358
|
+
grid-template-columns: 44px minmax(0, 1fr) $dropdown-column;
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
.--avatar .card-video__title {
|
|
@@ -469,7 +469,7 @@ $dropdown-column: 18px;
|
|
|
469
469
|
border-radius: 50%;
|
|
470
470
|
}
|
|
471
471
|
|
|
472
|
-
/* Кружок раскидан на обе строки: его
|
|
472
|
+
/* Кружок раскидан на обе строки: его 44px набираются заголовком плюс плашками, иначе под
|
|
473
473
|
однострочным заголовком остаётся пустой хвост высотой в остаток кружка */
|
|
474
474
|
.card-video__logo-wrapper {
|
|
475
475
|
grid-row: 1/3;
|
|
@@ -521,8 +521,8 @@ $dropdown-column: 18px;
|
|
|
521
521
|
.card-video__user {
|
|
522
522
|
@include font-xs;
|
|
523
523
|
|
|
524
|
-
width:
|
|
525
|
-
height:
|
|
524
|
+
width: 44px;
|
|
525
|
+
height: 44px;
|
|
526
526
|
display: grid;
|
|
527
527
|
place-content: center;
|
|
528
528
|
font-weight: 700;
|
|
@@ -64,19 +64,44 @@
|
|
|
64
64
|
</div>
|
|
65
65
|
</div>
|
|
66
66
|
<div
|
|
67
|
+
v-if="data.description"
|
|
67
68
|
:class="{'--show': showDescription}"
|
|
68
69
|
class="player-interface__description"
|
|
69
70
|
>
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
<div class="player-interface__description-body">
|
|
72
|
+
<p
|
|
73
|
+
ref="descriptionTextRef"
|
|
74
|
+
class="player-interface__description-text _truncate"
|
|
75
|
+
>{{
|
|
76
|
+
data.description
|
|
77
|
+
}}
|
|
78
|
+
</p>
|
|
79
|
+
<!-- Группы (контракт видео v2) — в раскрытом описании. В свёрнутом они скрыты стилями, но остаются в разметке:
|
|
80
|
+
ссылки читает краулер. -->
|
|
81
|
+
<dl
|
|
82
|
+
v-if="tagGroups.length"
|
|
83
|
+
class="player-interface__tags"
|
|
84
|
+
>
|
|
85
|
+
<div
|
|
86
|
+
v-for="group in tagGroups"
|
|
87
|
+
:key="group.name || group.title"
|
|
88
|
+
class="player-interface__tags-group"
|
|
89
|
+
>
|
|
90
|
+
<dt class="player-interface__tags-title">{{ group.title }}</dt>
|
|
91
|
+
<dd class="player-interface__tags-list">
|
|
92
|
+
<UiChips
|
|
93
|
+
v-for="item in group.items"
|
|
94
|
+
:key="item.name"
|
|
95
|
+
:item="convertTagGroupItemToChips(group.type, item)"
|
|
96
|
+
is-link
|
|
97
|
+
not-capitalize
|
|
98
|
+
/>
|
|
99
|
+
</dd>
|
|
100
|
+
</div>
|
|
101
|
+
</dl>
|
|
102
|
+
</div>
|
|
78
103
|
<button
|
|
79
|
-
v-if="
|
|
104
|
+
v-if="isDescriptionOverflowing || tagGroups.length"
|
|
80
105
|
:title="descriptionButtonText"
|
|
81
106
|
type="button"
|
|
82
107
|
class="player-interface__description-handler"
|
|
@@ -100,6 +125,7 @@
|
|
|
100
125
|
|
|
101
126
|
<script setup lang="ts">
|
|
102
127
|
import type { IVideoData } from '../../types';
|
|
128
|
+
import { convertTagGroupItemToChips } from '../../runtime';
|
|
103
129
|
import { AuthorizationApiService } from '../../services/api/authorization.service';
|
|
104
130
|
|
|
105
131
|
const props = defineProps<{
|
|
@@ -113,6 +139,8 @@ const showDescription = ref(false);
|
|
|
113
139
|
const descriptionTextRef = ref<HTMLParagraphElement>();
|
|
114
140
|
const isDescriptionOverflowing = ref(false);
|
|
115
141
|
|
|
142
|
+
const tagGroups = computed(() => props.data.tagGroups || []);
|
|
143
|
+
|
|
116
144
|
const descriptionButtonText = computed(() => showDescription.value ? t('hide_description') : t('show_description'));
|
|
117
145
|
|
|
118
146
|
function checkDescriptionOverflow() {
|
|
@@ -272,15 +300,19 @@ function onAddPlaylistClick() {
|
|
|
272
300
|
}
|
|
273
301
|
|
|
274
302
|
.player-interface__description {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
column-gap: 12px;
|
|
303
|
+
@include font-sm;
|
|
304
|
+
|
|
305
|
+
position: relative;
|
|
279
306
|
}
|
|
280
307
|
|
|
308
|
+
/* Кнопка — поверх блока в правом нижнем углу, как в макете: в свёрнутом стоит на второй строке описания (высота —
|
|
309
|
+
строка текста), в раскрытом — на последней строке тегов (высота — чипса), текст и теги при этом во всю ширину. */
|
|
281
310
|
.player-interface__description-handler {
|
|
282
311
|
@include font-sm;
|
|
283
312
|
|
|
313
|
+
position: absolute;
|
|
314
|
+
right: 0;
|
|
315
|
+
bottom: 0;
|
|
284
316
|
display: flex;
|
|
285
317
|
align-items: center;
|
|
286
318
|
height: 1.625em;
|
|
@@ -303,15 +335,87 @@ function onAddPlaylistClick() {
|
|
|
303
335
|
|
|
304
336
|
--line-clamp: 2;
|
|
305
337
|
|
|
338
|
+
padding-right: 28px;
|
|
306
339
|
line-height: 1.625;
|
|
307
340
|
color: $video-info-description-text;
|
|
308
341
|
}
|
|
309
342
|
|
|
310
343
|
.player-interface__description.--show .player-interface__description-text {
|
|
311
344
|
--line-clamp: auto;
|
|
345
|
+
|
|
346
|
+
padding-right: 0;
|
|
312
347
|
}
|
|
313
348
|
|
|
314
349
|
.player-interface__description.--show .player-interface__description-icon {
|
|
315
350
|
transform: rotate(180deg);
|
|
316
351
|
}
|
|
352
|
+
|
|
353
|
+
.player-interface__description.--show:has(.player-interface__tags) .player-interface__description-handler {
|
|
354
|
+
height: 36px;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.player-interface__tags {
|
|
358
|
+
display: none;
|
|
359
|
+
margin: 20px 0 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.player-interface__description.--show .player-interface__tags {
|
|
363
|
+
display: flex;
|
|
364
|
+
flex-direction: column;
|
|
365
|
+
gap: 16px;
|
|
366
|
+
|
|
367
|
+
@include from-br(sm) {
|
|
368
|
+
display: grid;
|
|
369
|
+
grid-template-columns: max-content minmax(0, 1fr);
|
|
370
|
+
gap: 8px 24px;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.player-interface__tags-group {
|
|
375
|
+
display: flex;
|
|
376
|
+
flex-direction: column;
|
|
377
|
+
gap: 8px;
|
|
378
|
+
|
|
379
|
+
@include from-br(sm) {
|
|
380
|
+
display: contents;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.player-interface__tags-title {
|
|
385
|
+
@include font-sm;
|
|
386
|
+
|
|
387
|
+
color: $video-info-tags-title;
|
|
388
|
+
|
|
389
|
+
@include from-br(sm) {
|
|
390
|
+
display: flex;
|
|
391
|
+
align-items: center;
|
|
392
|
+
height: 36px;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.player-interface__tags-list {
|
|
397
|
+
display: flex;
|
|
398
|
+
flex-wrap: wrap;
|
|
399
|
+
gap: 8px;
|
|
400
|
+
margin: 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.player-interface__tags .ui-chips {
|
|
404
|
+
@include font-2xs;
|
|
405
|
+
|
|
406
|
+
height: 36px;
|
|
407
|
+
padding-inline: 12px;
|
|
408
|
+
color: $video-info-tag-text;
|
|
409
|
+
background-color: $video-info-tag-bg;
|
|
410
|
+
border-radius: $radius-md;
|
|
411
|
+
|
|
412
|
+
@include from-br(sm) {
|
|
413
|
+
@include font-sm;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
@include hover(color, background-color) {
|
|
417
|
+
color: $video-info-tag-text--hover;
|
|
418
|
+
background-color: $video-info-tag-bg--hover;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
317
421
|
</style>
|
|
@@ -38,10 +38,8 @@
|
|
|
38
38
|
priority
|
|
39
39
|
>
|
|
40
40
|
<template #grid-end>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
hydrate-on-visible
|
|
44
|
-
/>
|
|
41
|
+
<!-- не ленивая: ленивая гидрация пересобирала заполненный сетью iframe, креатив терялся -->
|
|
42
|
+
<AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
|
|
45
43
|
</template>
|
|
46
44
|
</GridVideos>
|
|
47
45
|
<div
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
v-for="(model, index) in data.models"
|
|
30
30
|
:key="index"
|
|
31
31
|
:item="getModelChips(model)"
|
|
32
|
+
:class="{'view-video__model-chips': hasAsideBlock(model.title)}"
|
|
32
33
|
is-link
|
|
33
34
|
/>
|
|
34
35
|
</template>
|
|
@@ -75,11 +76,10 @@
|
|
|
75
76
|
/>
|
|
76
77
|
</template>
|
|
77
78
|
|
|
79
|
+
<!-- Без ленивой гидрации: контейнер приезжает в серверной разметке и сеть заполняет его до гидрации,
|
|
80
|
+
а ленивая гидрация пересобирала уже заполненный iframe — креатив терялся, слот оставался пустым. -->
|
|
78
81
|
<template #grid-end>
|
|
79
|
-
<
|
|
80
|
-
v-if="featureFlags.AdsNativeEnabled"
|
|
81
|
-
hydrate-on-visible
|
|
82
|
-
/>
|
|
82
|
+
<AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
|
|
83
83
|
</template>
|
|
84
84
|
</LazyGridVideos>
|
|
85
85
|
</section>
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
|
|
127
127
|
<script setup lang="ts">
|
|
128
128
|
import type { IChipsItem, IModelCard, IStripcashModel, IVideoCard, IVideoData, PaginatedResponse } from '../../types';
|
|
129
|
-
import { convertModelToRoundCard } from '../../runtime';
|
|
129
|
+
import { convertModelToRoundCard, convertString, convertTagGroupItemToChips } from '../../runtime';
|
|
130
130
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
131
131
|
import type { AsyncDataRequestStatus } from '#app';
|
|
132
132
|
|
|
@@ -181,11 +181,12 @@ const emit = defineEmits<{
|
|
|
181
181
|
(eventName: 'model-view', payload: { model: IStripcashModel, index: number, element: HTMLElement }): void
|
|
182
182
|
}>();
|
|
183
183
|
|
|
184
|
-
const tagChips = computed((): IChipsItem[] => props.data.tags?.map(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
184
|
+
const tagChips = computed((): IChipsItem[] => props.data.tags?.map(item => ({ title: item, value: item, prefix: 'tag' })) ?? []);
|
|
185
|
+
|
|
186
|
+
// v2: элементы всех групп одной строкой (без повторов) — категории ролика уже в них, отдельные не добавляем
|
|
187
|
+
const tagGroupChips = computed((): IChipsItem[] => [...new Map((props.data.tagGroups ?? [])
|
|
188
|
+
.flatMap(group => group.items.map(item => convertTagGroupItemToChips(group.type, item)))
|
|
189
|
+
.map(chip => [`${chip.prefix}/${chip.value}`, chip])).values()]);
|
|
189
190
|
|
|
190
191
|
const categoriesChips = computed((): IChipsItem[] => props.data.categories && props.data.categories?.map((item) => ({
|
|
191
192
|
title: item.title,
|
|
@@ -223,6 +224,8 @@ const topChips = computed(() => {
|
|
|
223
224
|
title: item,
|
|
224
225
|
prefix: 'search',
|
|
225
226
|
} as IChipsItem));
|
|
227
|
+
} else if (tagGroupChips.value.length) {
|
|
228
|
+
return tagGroupChips.value;
|
|
226
229
|
} else if (categoriesChips.value || filterTagChips.value) {
|
|
227
230
|
return [...categoriesChips.value || [], ...filterTagChips.value || []];
|
|
228
231
|
}
|
|
@@ -232,6 +235,15 @@ const topChips = computed(() => {
|
|
|
232
235
|
const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
|
|
233
236
|
|
|
234
237
|
/** Портрет в чипсе — квадратный кроп по лицу; своего у видео нет, его подмешивает BFF. */
|
|
238
|
+
// `videoPage.hideAsideModelChips`: с 1024px чипса модели не нужна тем, у кого есть блок в боковой колонке плеера
|
|
239
|
+
// (`useFetchAsideModels`). Снимок, а не реактивное чтение: список заполняется эффектом, и реактивный класс
|
|
240
|
+
// пересчитывался бы во время гидрации — разметка расходилась бы с серверной.
|
|
241
|
+
const asideModelSlugs = new Set(videoPage.hideAsideModelChips ? useAsideModelSlugs().value : []);
|
|
242
|
+
|
|
243
|
+
function hasAsideBlock(title: string): boolean {
|
|
244
|
+
return asideModelSlugs.has(convertString().toSlug(title));
|
|
245
|
+
}
|
|
246
|
+
|
|
235
247
|
const placeholderOf = useModelPlaceholder();
|
|
236
248
|
|
|
237
249
|
function getModelChips(model: IVideoData['models'][number]): IChipsItem {
|
|
@@ -283,6 +295,12 @@ function getModelChips(model: IVideoData['models'][number]): IChipsItem {
|
|
|
283
295
|
margin-top: 32px;
|
|
284
296
|
}
|
|
285
297
|
|
|
298
|
+
.view-video__model-chips {
|
|
299
|
+
@include from-br(sd) {
|
|
300
|
+
display: none;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
286
304
|
.view-video__popular-models {
|
|
287
305
|
margin-top: 32px;
|
|
288
306
|
}
|
|
@@ -36,10 +36,8 @@
|
|
|
36
36
|
priority
|
|
37
37
|
>
|
|
38
38
|
<template #grid-end>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
hydrate-on-visible
|
|
42
|
-
/>
|
|
39
|
+
<!-- не ленивая: ленивая гидрация пересобирала заполненный сетью iframe, креатив терялся -->
|
|
40
|
+
<AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
|
|
43
41
|
</template>
|
|
44
42
|
|
|
45
43
|
<LazySectionSwiperCards
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CategoriesApiService } from '../../services/api/categories.service';
|
|
2
2
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
3
3
|
import { sortItemsDefault } from '../../lib';
|
|
4
|
-
import type { IBreadcrumbItem, ICardInfo, IChipsItem } from '../../types';
|
|
4
|
+
import type { IBreadcrumbItem, ICardInfo, IChipsItem, INavLinkItem } from '../../types';
|
|
5
5
|
|
|
6
6
|
/** Слаги категорий, у которых не показываем связанные поисковые фразы. */
|
|
7
7
|
const DISALLOWED_SLUGS = ['18', '19'];
|
|
@@ -35,7 +35,11 @@ export async function useCategoryPageData() {
|
|
|
35
35
|
*/
|
|
36
36
|
export function useCategoryPage(
|
|
37
37
|
pageData: Awaited<ReturnType<typeof useCategoryPageData>>,
|
|
38
|
-
options: {
|
|
38
|
+
options: {
|
|
39
|
+
metaKeys?: boolean
|
|
40
|
+
/** Сортировки страницы (по умолчанию `sortItemsDefault`): по ним ключи меты и проверка `?sort` (чужой — 404). */
|
|
41
|
+
sortOptions?: INavLinkItem[]
|
|
42
|
+
} = {},
|
|
39
43
|
) {
|
|
40
44
|
const { categoryInfo, data, status, relatedData } = pageData;
|
|
41
45
|
const { t } = useI18n();
|
|
@@ -52,14 +56,16 @@ export function useCategoryPage(
|
|
|
52
56
|
useStatsPage(() => ({ rotatorID: 'category', categories: slug.value ? [slug.value] : [] }));
|
|
53
57
|
|
|
54
58
|
// с metaKeys `?sort` проверяет useMeta
|
|
55
|
-
|
|
59
|
+
const sortOptions = options.sortOptions || sortItemsDefault;
|
|
60
|
+
|
|
61
|
+
if (!options.metaKeys) useSortGuard(sortOptions);
|
|
56
62
|
|
|
57
63
|
const keysMeta = options.metaKeys
|
|
58
64
|
? useMeta({
|
|
59
65
|
t,
|
|
60
66
|
pageKey: useGetPureRouteName(),
|
|
61
67
|
brandName: useAppConfig().brandName,
|
|
62
|
-
sortOptions
|
|
68
|
+
sortOptions,
|
|
63
69
|
text: title,
|
|
64
70
|
nicheSafeText: title,
|
|
65
71
|
seo: () => ({
|
|
@@ -4,7 +4,7 @@ import type { Language } from '../../runtime';
|
|
|
4
4
|
import { sortItemsDefault } from '../../lib';
|
|
5
5
|
import { ChannelsApiService } from '../../services/api/channels.service';
|
|
6
6
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
7
|
-
import type { IBreadcrumbItem, IChipsItem, IPopularTags } from '../../types';
|
|
7
|
+
import type { IBreadcrumbItem, IChipsItem, IPopularTags, INavLinkItem } from '../../types';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Данные страницы канала: карточка канала, видео, популярные теги. Только запросы — их можно запускать в
|
|
@@ -37,7 +37,12 @@ export async function useChannelPageData() {
|
|
|
37
37
|
*/
|
|
38
38
|
export function useChannelPage(
|
|
39
39
|
pageData: Awaited<ReturnType<typeof useChannelPageData>>,
|
|
40
|
-
options: {
|
|
40
|
+
options: {
|
|
41
|
+
metaSort?: MaybeRefOrGetter<string>
|
|
42
|
+
metaSecondText?: Ref<string>
|
|
43
|
+
/** Сортировки страницы (по умолчанию `sortItemsDefault`): по ним ключи меты и проверка `?sort` (чужой — 404). */
|
|
44
|
+
sortOptions?: INavLinkItem[]
|
|
45
|
+
} = {},
|
|
41
46
|
) {
|
|
42
47
|
const { cardInfo, status, channelTagsData } = pageData;
|
|
43
48
|
const route = useRoute();
|
|
@@ -50,7 +55,7 @@ export function useChannelPage(
|
|
|
50
55
|
t,
|
|
51
56
|
pageKey: useGetPureRouteName(),
|
|
52
57
|
brandName: useAppConfig().brandName,
|
|
53
|
-
sortOptions: sortItemsDefault,
|
|
58
|
+
sortOptions: options.sortOptions || sortItemsDefault,
|
|
54
59
|
sort: options.metaSort,
|
|
55
60
|
text: metaTitle,
|
|
56
61
|
secondText: options.metaSecondText,
|
|
@@ -4,7 +4,7 @@ import type { Language } from '../../runtime';
|
|
|
4
4
|
import { sortItemsDefault } from '../../lib';
|
|
5
5
|
import { ModelsApiService } from '../../services/api/models.service';
|
|
6
6
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
7
|
-
import type { IBreadcrumbItem } from '../../types';
|
|
7
|
+
import type { IBreadcrumbItem, INavLinkItem } from '../../types';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Данные страницы модели: видео и карточка модели. Только запросы — их можно запускать в `Promise.all` вместе
|
|
@@ -34,7 +34,12 @@ export async function useModelPageData() {
|
|
|
34
34
|
*/
|
|
35
35
|
export function useModelPage(
|
|
36
36
|
pageData: Awaited<ReturnType<typeof useModelPageData>>,
|
|
37
|
-
options: {
|
|
37
|
+
options: {
|
|
38
|
+
metaSort?: MaybeRefOrGetter<string>
|
|
39
|
+
metaSecondText?: Ref<string>
|
|
40
|
+
/** Сортировки страницы (по умолчанию `sortItemsDefault`): по ним ключи меты и проверка `?sort` (чужой — 404). */
|
|
41
|
+
sortOptions?: INavLinkItem[]
|
|
42
|
+
} = {},
|
|
38
43
|
) {
|
|
39
44
|
const { status, cardInfo } = pageData;
|
|
40
45
|
const route = useRoute();
|
|
@@ -48,7 +53,7 @@ export function useModelPage(
|
|
|
48
53
|
t,
|
|
49
54
|
pageKey: useGetPureRouteName(),
|
|
50
55
|
brandName: useAppConfig().brandName,
|
|
51
|
-
sortOptions: sortItemsDefault,
|
|
56
|
+
sortOptions: options.sortOptions || sortItemsDefault,
|
|
52
57
|
sort: options.metaSort,
|
|
53
58
|
text: metaTitle,
|
|
54
59
|
secondText: options.metaSecondText,
|
package/package.json
CHANGED
|
@@ -33,10 +33,8 @@
|
|
|
33
33
|
:class="{'_loading': status === 'pending'}"
|
|
34
34
|
>
|
|
35
35
|
<template #grid-end>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
hydrate-on-visible
|
|
39
|
-
/>
|
|
36
|
+
<!-- не ленивая: ленивая гидрация пересобирала заполненный сетью iframe, креатив терялся -->
|
|
37
|
+
<AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
|
|
40
38
|
</template>
|
|
41
39
|
<template #default>
|
|
42
40
|
<UiExpandRow
|
|
@@ -41,10 +41,8 @@
|
|
|
41
41
|
priority
|
|
42
42
|
>
|
|
43
43
|
<template #grid-end>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
hydrate-on-visible
|
|
47
|
-
/>
|
|
44
|
+
<!-- не ленивая: ленивая гидрация пересобирала заполненный сетью iframe, креатив терялся -->
|
|
45
|
+
<AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
|
|
48
46
|
</template>
|
|
49
47
|
<template #default>
|
|
50
48
|
<LazySectionSwiperCards
|
|
@@ -78,10 +78,8 @@
|
|
|
78
78
|
priority
|
|
79
79
|
>
|
|
80
80
|
<template #grid-end>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
hydrate-on-visible
|
|
84
|
-
/>
|
|
81
|
+
<!-- не ленивая: ленивая гидрация пересобирала заполненный сетью iframe, креатив терялся -->
|
|
82
|
+
<AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
|
|
85
83
|
<UiViewAll
|
|
86
84
|
v-if="videosData.items.length > MAXIMUM_VIDEOS || videosData.total > MAXIMUM_VIDEOS"
|
|
87
85
|
:to="`/search/${slug}/videos`"
|
package/runtime/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ export * from './utils/cleaners/clean-suggest-models';
|
|
|
47
47
|
export * from './utils/video-data-add-model-portraits';
|
|
48
48
|
export * from './utils/converters/convert-categories-to-footer';
|
|
49
49
|
export * from './utils/converters/convert-categories-to-chips';
|
|
50
|
+
export * from './utils/converters/convert-tag-group-item-to-chips';
|
|
50
51
|
export * from './utils/converters/convert-model-card-to-chips';
|
|
51
52
|
export * from './utils/converters/group-categories-by-first-letter';
|
|
52
53
|
export * from './utils/converters/group-objects-by-first-letter';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { cleanVideoData } from '../clean-video-data';
|
|
3
|
+
import type { IRawVideoData } from '../../../../types';
|
|
3
4
|
|
|
4
5
|
describe('cleanVideoData', () => {
|
|
5
6
|
it('полные данные', () => {
|
|
@@ -170,3 +171,36 @@ describe('cleanVideoData', () => {
|
|
|
170
171
|
expect(result.dislikes).toBe(0);
|
|
171
172
|
});
|
|
172
173
|
});
|
|
174
|
+
|
|
175
|
+
const raw = (tags: unknown) => ({ tags } as unknown as IRawVideoData);
|
|
176
|
+
|
|
177
|
+
describe('cleanVideoData — теги', () => {
|
|
178
|
+
it('v1: строки остаются плоским списком, групп нет', () => {
|
|
179
|
+
const data = cleanVideoData(raw(['Anal', 'Big Ass']));
|
|
180
|
+
expect(data.tags).toEqual(['Anal', 'Big Ass']);
|
|
181
|
+
expect(data.tagGroups).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('v2: группы по order, элементы внутри по order, пустые и неизвестного типа выкинуты, плоский список — склейка без повторов', () => {
|
|
185
|
+
const data = cleanVideoData(raw([
|
|
186
|
+
{ type: 'category', name: 'positions', title: 'Positions', order: 1, items: [{ name: 'doggy', title: 'Doggy style' }] },
|
|
187
|
+
{ type: 'category', name: 'empty', title: 'Empty', order: 2, items: [] },
|
|
188
|
+
{ type: 'unknown', name: 'odd', title: 'Odd', order: 3, items: [{ name: 'odd', title: 'Odd' }] },
|
|
189
|
+
{ type: 'tag', name: 'actions', title: 'Actions', order: 0, items: [
|
|
190
|
+
{ name: 'rimming', title: 'Rimming', order: 2 },
|
|
191
|
+
{ name: 'anal', title: 'Anal', order: 1 },
|
|
192
|
+
{ name: 'doggy', title: 'Doggy style', order: 3 },
|
|
193
|
+
] },
|
|
194
|
+
]));
|
|
195
|
+
|
|
196
|
+
expect(data.tagGroups.map(group => group.name)).toEqual(['actions', 'positions']);
|
|
197
|
+
expect(data.tagGroups[0].type).toBe('tag');
|
|
198
|
+
expect(data.tagGroups[0].items.map(item => item.name)).toEqual(['anal', 'rimming', 'doggy']);
|
|
199
|
+
expect(data.tags).toEqual(['Anal', 'Rimming', 'Doggy style']);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('нет тегов — пусто', () => {
|
|
203
|
+
expect(cleanVideoData(raw(undefined)).tagGroups).toEqual([]);
|
|
204
|
+
expect(cleanVideoData(raw(undefined)).tags).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IRawVideoData, IVideoData, ISceneGroup, ITimelineGroup } from '../../../types';
|
|
1
|
+
import type { IRawVideoData, IVideoData, ISceneGroup, ITimelineGroup, IVideoTagGroup } from '../../../types';
|
|
2
2
|
import { pickPortraitVariant } from '../pick-portrait-variant';
|
|
3
3
|
import { modelGender } from '../model-placeholder';
|
|
4
4
|
|
|
@@ -64,6 +64,31 @@ const cleanVideoModels = (models: IRawVideoData['models'], theme?: string): IVid
|
|
|
64
64
|
});
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
const byOrder = <T extends { order?: number }>(a: T, b: T) => (a.order ?? 0) - (b.order ?? 0);
|
|
68
|
+
|
|
69
|
+
const TAG_GROUP_TYPES = new Set<string>(['tag', 'category', 'search']);
|
|
70
|
+
|
|
71
|
+
// v2 отдаёт в `tags` группы, v1 — строки. Группы сортируются по `order` (и элементы внутри, если он задан);
|
|
72
|
+
// пустые и неизвестного типа (ссылку не построить) отбрасываются; плоский список — склейка элементов всех групп без повторов.
|
|
73
|
+
const cleanTags = (raw: IRawVideoData['tags']): { tags: string[], tagGroups: IVideoTagGroup[] } => {
|
|
74
|
+
if (!Array.isArray(raw) || !raw.length) return { tags: [], tagGroups: [] };
|
|
75
|
+
if (typeof raw[0] === 'string') return { tags: raw as string[], tagGroups: [] };
|
|
76
|
+
|
|
77
|
+
const tagGroups = (raw as IVideoTagGroup[])
|
|
78
|
+
.filter(group => group?.title && TAG_GROUP_TYPES.has(group.type) && Array.isArray(group.items))
|
|
79
|
+
.map(group => ({
|
|
80
|
+
type: group.type,
|
|
81
|
+
name: group.name || '',
|
|
82
|
+
title: group.title,
|
|
83
|
+
order: group.order ?? 0,
|
|
84
|
+
items: group.items.filter(item => item?.title && item.name).sort(byOrder),
|
|
85
|
+
}))
|
|
86
|
+
.filter(group => group.items.length > 0)
|
|
87
|
+
.sort(byOrder);
|
|
88
|
+
|
|
89
|
+
return { tags: [...new Set(tagGroups.flatMap(group => group.items.map(item => item.title)))], tagGroups };
|
|
90
|
+
};
|
|
91
|
+
|
|
67
92
|
/** Нормализует сырые данные видео к типу IVideoData. */
|
|
68
93
|
export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData => ({
|
|
69
94
|
guid: data.guid || '',
|
|
@@ -74,7 +99,7 @@ export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData
|
|
|
74
99
|
views: data.views || 0,
|
|
75
100
|
likes: data.likes || 0,
|
|
76
101
|
id: data.id || '',
|
|
77
|
-
|
|
102
|
+
...cleanTags(data.tags),
|
|
78
103
|
models: cleanVideoModels(data.models, theme),
|
|
79
104
|
categories: data.categories || [],
|
|
80
105
|
actions: data.actions || null,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IChipsItem, IVideoTagGroupItem, VideoTagGroupType } from '../../../types';
|
|
2
|
+
|
|
3
|
+
const PREFIX: Record<VideoTagGroupType, string> = { tag: 'tag', category: 'categories', search: 'search' };
|
|
4
|
+
|
|
5
|
+
/** Элемент группы под плеером (контракт видео v2) → чипса: ссылка по типу группы и машинному `name`, показ по `title`. */
|
|
6
|
+
export function convertTagGroupItemToChips(type: VideoTagGroupType, item: IVideoTagGroupItem): IChipsItem {
|
|
7
|
+
return {
|
|
8
|
+
title: item.title,
|
|
9
|
+
value: item.name,
|
|
10
|
+
prefix: PREFIX[type],
|
|
11
|
+
// как у чипс категорий ролика: имя иконки — флаг, а URL берём как есть
|
|
12
|
+
...(item.icon ? { image: item.icon.includes('/') ? item.icon : `/icons/flags/${item.icon}.svg`, imageFit: 'contain' as const } : {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -13,6 +13,7 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
|
13
13
|
'user-agent': requestHeaders[ 'user-agent' ] ?? '',
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
// ручка видео v2 (`/v2/video/id/…`, теги — группами) ещё не готова: v1 + мок групп (`withMockTagGroups`)
|
|
16
17
|
const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v1/video/id/${id}`, event);
|
|
17
18
|
const modelsUrl = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-video-id/${id}`, event);
|
|
18
19
|
|
|
@@ -24,7 +25,7 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
|
24
25
|
apiFetch(modelsUrl, { method: 'GET', headers: clientHeaders }).catch(() => null),
|
|
25
26
|
]);
|
|
26
27
|
|
|
27
|
-
const videoData = videoRaw.data as IRawVideoData;
|
|
28
|
+
const videoData = withMockTagGroups(videoRaw.data as IRawVideoData);
|
|
28
29
|
const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
|
|
29
30
|
|
|
30
31
|
return cleanVideoData(
|
|
@@ -12,6 +12,7 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
|
12
12
|
...requestHeaders,
|
|
13
13
|
'user-agent': requestHeaders[ 'user-agent' ] ?? '',
|
|
14
14
|
};
|
|
15
|
+
// ручка видео v2 (`/v2/video/url/…`, теги — группами) ещё не готова: v1 + мок групп (`withMockTagGroups`)
|
|
15
16
|
const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v1/video/url/${url}`, event);
|
|
16
17
|
const modelsUrl = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-video-url/${url}`, event);
|
|
17
18
|
|
|
@@ -23,7 +24,7 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
|
23
24
|
apiFetch(modelsUrl, { method: 'GET', headers: clientHeaders }).catch(() => null),
|
|
24
25
|
]);
|
|
25
26
|
|
|
26
|
-
const videoData = videoRaw.data as IRawVideoData;
|
|
27
|
+
const videoData = withMockTagGroups(videoRaw.data as IRawVideoData);
|
|
27
28
|
const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
|
|
28
29
|
|
|
29
30
|
return cleanVideoData(
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { IRawVideoData, IVideoTagGroup } from '../../types';
|
|
2
|
+
|
|
3
|
+
const item = (name: string) => ({ name, title: name.charAt(0).toUpperCase() + name.slice(1) });
|
|
4
|
+
const group = (order: number, name: string, title: string, names: string[], type: IVideoTagGroup['type'] = 'category'): IVideoTagGroup =>
|
|
5
|
+
({ type, name, title, order, items: names.map(item) });
|
|
6
|
+
|
|
7
|
+
/** Статичная заглушка групп (контракт ручки видео v2): настоящие категории, теги и поиски сайта (все три типа узлов),
|
|
8
|
+
* категорийных групп и элементов — как в макете. Одна на все ролики — только для отладки вида и поведения; группировать будет бекенд. */
|
|
9
|
+
const MOCK_TAG_GROUPS: IVideoTagGroup[] = [
|
|
10
|
+
group(0, 'actions', 'Actions', ['blowjob', 'cumshot', 'creampie', 'deep throat', 'handjob', 'fingering', 'squirt']),
|
|
11
|
+
group(1, 'positions', 'Positions', ['cowgirl', 'missionary', 'face sitting']),
|
|
12
|
+
group(2, 'body_type', 'Body type', ['big boobs', 'big ass', 'petite', 'big cock']),
|
|
13
|
+
group(3, 'appearance', 'Appearance', ['blond', 'brunette', 'redhead', 'tattoo']),
|
|
14
|
+
group(4, 'location', 'Location', ['bedroom']),
|
|
15
|
+
group(5, 'format', 'Format', ['amateur', 'point of view', 'compilation', 'homemade']),
|
|
16
|
+
group(6, 'tags', 'Tags', ['moaning', 'loud moaning', 'female orgasm', 'bouncing tits', 'natural big tits'], 'tag'),
|
|
17
|
+
group(7, 'searches', 'Related searches', ['amateur wife', 'big tits teen', 'homemade blowjob'], 'search'),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// заглушка нарочно lorem — сразу видно, что не настоящее описание
|
|
21
|
+
const MOCK_DESCRIPTION = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et '
|
|
22
|
+
+ 'dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo '
|
|
23
|
+
+ 'consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* shortcut: ручка видео v2 ещё не готова — BFF ходит в v1 и отдаёт вместо тегов статичную заглушку групп (и описание,
|
|
27
|
+
* если его нет: у роликов с группами оно будет всегда). Удалить вместе с переходом на `/v2/video/*`.
|
|
28
|
+
*/
|
|
29
|
+
export function withMockTagGroups(video: IRawVideoData): IRawVideoData {
|
|
30
|
+
if (!video) return video;
|
|
31
|
+
|
|
32
|
+
return { ...video, tags: MOCK_TAG_GROUPS, description: video.description || MOCK_DESCRIPTION };
|
|
33
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IRawThumbUrls } from './raw-thumbs-urls';
|
|
2
2
|
import type { IRawPortraits } from './raw-portraits';
|
|
3
|
-
import type { ITimelineGroup } from '../video-data';
|
|
3
|
+
import type { ITimelineGroup, IVideoTagGroup } from '../video-data';
|
|
4
4
|
|
|
5
5
|
export interface IRawVideoData {
|
|
6
6
|
guid: string;
|
|
@@ -18,7 +18,8 @@ export interface IRawVideoData {
|
|
|
18
18
|
dislikes: number;
|
|
19
19
|
popularity: number; // unused
|
|
20
20
|
id: string;
|
|
21
|
-
|
|
21
|
+
/** v1 — названия тегов; v2 — группы (`IVideoTagGroup[]`). */
|
|
22
|
+
tags: string[] | IVideoTagGroup[];
|
|
22
23
|
models: {
|
|
23
24
|
guid: string;
|
|
24
25
|
title: string;
|
package/types/video-data.d.ts
CHANGED
|
@@ -29,6 +29,27 @@ export interface ISceneGroupScene {
|
|
|
29
29
|
title: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** Тип узла группы (контракт ручки видео v2): по нему строится ссылка элементов — тег, категория или поиск. */
|
|
33
|
+
export type VideoTagGroupType = 'tag' | 'category' | 'search';
|
|
34
|
+
|
|
35
|
+
/** Элемент группы (контракт ручки видео v2). `name` — машинное название (ссылка), `title` — для показа. */
|
|
36
|
+
export interface IVideoTagGroupItem {
|
|
37
|
+
name: string;
|
|
38
|
+
title: string;
|
|
39
|
+
order?: number;
|
|
40
|
+
/** Имя иконки или URL. */
|
|
41
|
+
icon?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Группа под плеером (контракт ручки видео v2): категории, сгруппированные бекендом, — «Actions», «Positions»… */
|
|
45
|
+
export interface IVideoTagGroup {
|
|
46
|
+
type: VideoTagGroupType;
|
|
47
|
+
name: string;
|
|
48
|
+
title: string;
|
|
49
|
+
order: number;
|
|
50
|
+
items: IVideoTagGroupItem[];
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
export interface ISceneGroup {
|
|
33
54
|
id: string;
|
|
34
55
|
title: string;
|
|
@@ -45,7 +66,10 @@ export interface IVideoData {
|
|
|
45
66
|
likes: number;
|
|
46
67
|
dislikes: number;
|
|
47
68
|
id: string;
|
|
69
|
+
/** Все теги одним списком: в v2 — склейка элементов всех групп. */
|
|
48
70
|
tags: string[];
|
|
71
|
+
/** Группы под плеером (раскрытое описание); у ответа v1 групп нет — пусто. */
|
|
72
|
+
tagGroups: IVideoTagGroup[];
|
|
49
73
|
models: {
|
|
50
74
|
guid: string;
|
|
51
75
|
title: string;
|