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 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="32"
34
- height="32"
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: 32px;
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="32"
83
- height="32"
84
- sizes="32px"
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: 36px minmax(0, 1fr) $dropdown-column;
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: 36px minmax(0, 1fr) $dropdown-column;
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
- /* Кружок раскидан на обе строки: его 36px набираются заголовком плюс плашками, иначе под
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: 36px;
525
- height: 36px;
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
- <p
71
- v-if="data.description"
72
- ref="descriptionTextRef"
73
- class="player-interface__description-text _truncate"
74
- >{{
75
- data.description
76
- }}
77
- </p>
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="data.description && isDescriptionOverflowing"
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
- display: grid;
276
- grid-template-columns: 1fr auto;
277
- align-items: end;
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((item) => ({
185
- title: item,
186
- value: item,
187
- prefix: 'tag',
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1053",
4
+ "version": "0.0.1054",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
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
- tags: data.tags || [],
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
- tags: string[];
21
+ /** v1 — названия тегов; v2 — группы (`IVideoTagGroup[]`). */
22
+ tags: string[] | IVideoTagGroup[];
22
23
  models: {
23
24
  guid: string;
24
25
  title: string;
@@ -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;