itube-specs 0.0.1053 → 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 +15 -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/interface.vue +117 -13
- package/components/view/video.vue +9 -6
- package/package.json +1 -1
- 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/raw/raw-video-data.d.ts +3 -2
- package/types/video-data.d.ts +24 -0
package/SITE_UPDATES.md
CHANGED
|
@@ -11,6 +11,21 @@
|
|
|
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
|
+
|
|
14
29
|
## 0.0.1053 — 2026-10-26
|
|
15
30
|
|
|
16
31
|
- **Всем — к сведению:** нативка в конце сеток листингов и похожих (`AdvNativeBottom` в `view/video`, `view/videos`,
|
|
@@ -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>
|
|
@@ -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, convertString } 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
|
}
|
package/package.json
CHANGED
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;
|