itube-specs 0.0.1053 → 0.0.1055

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,36 @@
11
11
  коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
12
12
  кому именно (всем / сайтам с N), что будет, если не сделать.
13
13
 
14
+ ## 0.0.1055 — 2026-10-30
15
+
16
+ - **Всем — к сведению:** related searches на странице категории (`useCategoryPage().relatedSearch`) теперь
17
+ отсортированы по убыванию `score` (раньше — в порядке ответа API). Новый ключ `appConfig.categoryRelatedSearchLimit`
18
+ (по умолчанию `null` — все пришедшие фразы, как было): число — показывать только топ-N по весу. Делать ничего не
19
+ надо; videosection-redesign ставит `20`, как на боевом videosection.
20
+
21
+ - **Всем — к сведению:** группы под плеером читаются из поля `entities` ответа видео v2 (бекенд: разделы категорий,
22
+ затем теги и поисковые фразы); `tags` снова только `string[]`. У узлов `tag`/`search` бекенд отдаёт в `title` сам
23
+ тип — заголовок берётся из переводов (новый ключ `tags`, и `related_searches`). BFF обеих ручек видео (по id и по
24
+ url) ходит в `GET /v2/video/url/{url}` — бекенд сам разбирает id или слаг; заглушка групп удалена. Временное демо (`server/utils/video-demo-mock.ts`): без описания — lorem, без разделов
25
+ категорий в `entities` — категории ролика одной группой «Categories»; уберём, когда данные появятся.
26
+ **Нужен бекенд с этой ручкой** (itube-gw `9d57077`): на API без неё страница видео отдаёт 500. Сайтам со своими локалями (hdsex `hi-IN`) — добавить `tags` и
27
+ `related_searches`, иначе заголовок будет на английском.
28
+
29
+ ## 0.0.1054 — 2026-10-28
30
+
31
+ - **Сайтам со своей темой (hdsex-remaster):** в `_theme.scss` добавить токены тегов в раскрытом описании видео —
32
+ `$video-info-tags-title: $color-zinc-500; $video-info-tag-text: $color-zinc-300; $video-info-tag-text--hover:
33
+ $color-white; $video-info-tag-bg: $color-zinc-800; $video-info-tag-bg--hover: $color-zinc-700;` (светлая тема слоя —
34
+ серая шкала). Не сделать — сборка упадёт на неизвестном токене.
35
+ - **Всем — к сведению:** описание видео (`PlayerInterface`) по новому макету: в раскрытом виде под текстом — группы
36
+ (`IVideoData.tagGroups`), кнопка раскрытия стоит в нижней строке блока (свёрнуто — на второй строке описания,
37
+ раскрыто — на последней строке групп). Ручка видео переходит на v2: в `tags` — узлы `IVideoTagGroup`
38
+ (`type: 'tag' | 'category' | 'search'`, `items: IVideoTagGroupItem[]`), ссылка элемента строится по типу узла
39
+ (`convertTagGroupItemToChips`); строка над плеером — склейка элементов всех групп. Пока бекенд v2 не готов, BFF ходит
40
+ в v1 и отдаёт одну статичную заглушку групп из категорий сайта (и lorem-описание, если своего нет) —
41
+ `server/utils/video-tag-groups-mock.ts`, уберём, когда группы начнут приходить. Делать ничего не надо.
42
+ - **Всем — к сведению:** кружок канала на тумбе видео и в листинге каналов — 44px (было 32). Делать ничего не надо.
43
+
14
44
  ## 0.0.1053 — 2026-10-26
15
45
 
16
46
  - **Всем — к сведению:** нативка в конце сеток листингов и похожих (`AdvNativeBottom` в `view/video`, `view/videos`,
package/app.config.ts CHANGED
@@ -14,6 +14,8 @@ export default defineAppConfig({
14
14
  /** Есть ли у сайта сгруппированный вид категорий (`/categories` — группы, `/categories/all` — A–Z).
15
15
  * `false` — `/categories` сам список A–Z: `CategoryNav` без табов Grouped / A-Z, «All» ведёт на `/categories`. */
16
16
  categoriesGrouped: true,
17
+ /** Сколько связанных поисковых фраз (по убыванию веса) показывать на странице категории, `null` — все, что пришли. */
18
+ categoryRelatedSearchLimit: null as number | null,
17
19
  /** Блок Live Cams в сетках листингов (при `FeatureWebcamsEnabled`), `null` — нет. `videos` — после какой строки
18
20
  * карточек и сколько врезок вебкам-тумб (хвост того же списка) в сетке видео листинга (не похожие, не плейлист,
19
21
  * не когда страница передала врезки сама); `models` — в основной сетке моделей (`priority`, не футер). */
@@ -21,6 +21,18 @@
21
21
  display: block;
22
22
  width: var(--size, 64px);
23
23
  height: var(--size, 64px);
24
- content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><radialGradient id="a12" cx=".66" fx=".66" cy=".3125" fy=".3125" gradientTransform="scale(1.5)"><stop offset="0" stop-color="%23FFFFFF"></stop><stop offset=".3" stop-color="%23FFFFFF" stop-opacity=".9"></stop><stop offset=".6" stop-color="%23FFFFFF" stop-opacity=".6"></stop><stop offset=".8" stop-color="%23FFFFFF" stop-opacity=".3"></stop><stop offset="1" stop-color="%23FFFFFF" stop-opacity="0"></stop></radialGradient><circle transform-origin="center" fill="none" stroke="url(%23a12)" stroke-width="15" stroke-linecap="round" stroke-dasharray="200 1000" stroke-dashoffset="0" cx="100" cy="100" r="70"><animateTransform type="rotate" attributeName="transform" calcMode="spline" dur="2" values="360;0" keyTimes="0;1" keySplines="0 0 1 1" repeatCount="indefinite"></animateTransform></circle><circle transform-origin="center" fill="none" opacity=".2" stroke="%23FFFFFF" stroke-width="15" stroke-linecap="round" cx="100" cy="100" r="70"></circle></svg>');
24
+ background-color: $loading-icon;
25
+ content: '';
26
+ mask-position: center;
27
+ mask-size: contain;
28
+ mask-repeat: no-repeat;
29
+ mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><radialGradient id="a12" cx=".66" fx=".66" cy=".3125" fy=".3125" gradientTransform="scale(1.5)"><stop offset="0" stop-color="%23FFFFFF"></stop><stop offset=".3" stop-color="%23FFFFFF" stop-opacity=".9"></stop><stop offset=".6" stop-color="%23FFFFFF" stop-opacity=".6"></stop><stop offset=".8" stop-color="%23FFFFFF" stop-opacity=".3"></stop><stop offset="1" stop-color="%23FFFFFF" stop-opacity="0"></stop></radialGradient><circle transform-origin="center" fill="none" stroke="url(%23a12)" stroke-width="15" stroke-linecap="round" stroke-dasharray="200 1000" stroke-dashoffset="0" cx="100" cy="100" r="70"></circle><circle transform-origin="center" fill="none" opacity=".2" stroke="%23FFFFFF" stroke-width="15" stroke-linecap="round" cx="100" cy="100" r="70"></circle></svg>');
30
+ animation: loading-spin 2s linear infinite;
31
+ }
32
+ }
33
+
34
+ @keyframes loading-spin {
35
+ to {
36
+ transform: translate(-50%, -50%) rotate(-360deg);
25
37
  }
26
38
  }
@@ -16,6 +16,9 @@ $main-bg: $color-black;
16
16
  // ─── Link ───
17
17
  $link-text: $color-white;
18
18
 
19
+ // ─── Loading ───
20
+ $loading-icon: $color-white;
21
+
19
22
  // ─── Cards (shared) ───
20
23
  $card-ring-shadow--hover: none;
21
24
 
@@ -241,6 +244,11 @@ $video-info-item-text: $color-zinc-500-70;
241
244
  $video-info-handler-text: $color-zinc-500;
242
245
  $video-info-handler-text--hover: $color-white;
243
246
  $video-info-description-text: $color-zinc-400;
247
+ $video-info-tags-title: $color-zinc-500;
248
+ $video-info-tag-text: $color-zinc-300;
249
+ $video-info-tag-text--hover: $color-white;
250
+ $video-info-tag-bg: $color-zinc-800;
251
+ $video-info-tag-bg--hover: $color-zinc-700;
244
252
 
245
253
  // ─── Video interface ───
246
254
  $video-interface-button-bg: $color-zinc-800;
@@ -27,6 +27,9 @@ $main-bg: $color-white;
27
27
  // ─── Link ───
28
28
  $link-text: $color-indigo-600;
29
29
 
30
+ // ─── Loading ───
31
+ $loading-icon: $color-indigo-600;
32
+
30
33
  // ─── Cards (shared) ───
31
34
  $card-ring-shadow--hover: 0 0 0 2px $main-bg, 0 0 0 3px $color-gray-400;
32
35
 
@@ -297,6 +300,11 @@ $video-info-item-text: $color-gray-400;
297
300
  $video-info-handler-text: $color-gray-400;
298
301
  $video-info-handler-text--hover: $color-gray-900;
299
302
  $video-info-description-text: $color-gray-700;
303
+ $video-info-tags-title: $color-gray-500;
304
+ $video-info-tag-text: $color-gray-700;
305
+ $video-info-tag-text--hover: $color-gray-950;
306
+ $video-info-tag-bg: $color-gray-100;
307
+ $video-info-tag-bg--hover: $color-gray-200;
300
308
 
301
309
  // ─── Video interface ───
302
310
  $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,45 @@
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
+ <!-- у узлов тегов и поисков бекенд кладёт в title сам тип — заголовок берём из переводов -->
91
+ <dt class="player-interface__tags-title">{{ group.type === 'category' ? group.title : t(group.type === 'tag' ? 'tags' : 'related_searches') }}</dt>
92
+ <dd class="player-interface__tags-list">
93
+ <UiChips
94
+ v-for="item in group.items"
95
+ :key="item.name"
96
+ :item="convertTagGroupItemToChips(group.type, item)"
97
+ is-link
98
+ not-capitalize
99
+ />
100
+ </dd>
101
+ </div>
102
+ </dl>
103
+ </div>
78
104
  <button
79
- v-if="data.description && isDescriptionOverflowing"
105
+ v-if="isDescriptionOverflowing || tagGroups.length"
80
106
  :title="descriptionButtonText"
81
107
  type="button"
82
108
  class="player-interface__description-handler"
@@ -100,6 +126,7 @@
100
126
 
101
127
  <script setup lang="ts">
102
128
  import type { IVideoData } from '../../types';
129
+ import { convertTagGroupItemToChips } from '../../runtime';
103
130
  import { AuthorizationApiService } from '../../services/api/authorization.service';
104
131
 
105
132
  const props = defineProps<{
@@ -113,6 +140,8 @@ const showDescription = ref(false);
113
140
  const descriptionTextRef = ref<HTMLParagraphElement>();
114
141
  const isDescriptionOverflowing = ref(false);
115
142
 
143
+ const tagGroups = computed(() => props.data.tagGroups || []);
144
+
116
145
  const descriptionButtonText = computed(() => showDescription.value ? t('hide_description') : t('show_description'));
117
146
 
118
147
  function checkDescriptionOverflow() {
@@ -272,15 +301,19 @@ function onAddPlaylistClick() {
272
301
  }
273
302
 
274
303
  .player-interface__description {
275
- display: grid;
276
- grid-template-columns: 1fr auto;
277
- align-items: end;
278
- column-gap: 12px;
304
+ @include font-sm;
305
+
306
+ position: relative;
279
307
  }
280
308
 
309
+ /* Кнопка — поверх блока в правом нижнем углу, как в макете: в свёрнутом стоит на второй строке описания (высота —
310
+ строка текста), в раскрытом — на последней строке тегов (высота — чипса), текст и теги при этом во всю ширину. */
281
311
  .player-interface__description-handler {
282
312
  @include font-sm;
283
313
 
314
+ position: absolute;
315
+ right: 0;
316
+ bottom: 0;
284
317
  display: flex;
285
318
  align-items: center;
286
319
  height: 1.625em;
@@ -303,15 +336,87 @@ function onAddPlaylistClick() {
303
336
 
304
337
  --line-clamp: 2;
305
338
 
339
+ padding-right: 28px;
306
340
  line-height: 1.625;
307
341
  color: $video-info-description-text;
308
342
  }
309
343
 
310
344
  .player-interface__description.--show .player-interface__description-text {
311
345
  --line-clamp: auto;
346
+
347
+ padding-right: 0;
312
348
  }
313
349
 
314
350
  .player-interface__description.--show .player-interface__description-icon {
315
351
  transform: rotate(180deg);
316
352
  }
353
+
354
+ .player-interface__description.--show:has(.player-interface__tags) .player-interface__description-handler {
355
+ height: 36px;
356
+ }
357
+
358
+ .player-interface__tags {
359
+ display: none;
360
+ margin: 20px 0 0;
361
+ }
362
+
363
+ .player-interface__description.--show .player-interface__tags {
364
+ display: flex;
365
+ flex-direction: column;
366
+ gap: 16px;
367
+
368
+ @include from-br(sm) {
369
+ display: grid;
370
+ grid-template-columns: max-content minmax(0, 1fr);
371
+ gap: 8px 24px;
372
+ }
373
+ }
374
+
375
+ .player-interface__tags-group {
376
+ display: flex;
377
+ flex-direction: column;
378
+ gap: 8px;
379
+
380
+ @include from-br(sm) {
381
+ display: contents;
382
+ }
383
+ }
384
+
385
+ .player-interface__tags-title {
386
+ @include font-sm;
387
+
388
+ color: $video-info-tags-title;
389
+
390
+ @include from-br(sm) {
391
+ display: flex;
392
+ align-items: center;
393
+ height: 36px;
394
+ }
395
+ }
396
+
397
+ .player-interface__tags-list {
398
+ display: flex;
399
+ flex-wrap: wrap;
400
+ gap: 8px;
401
+ margin: 0;
402
+ }
403
+
404
+ .player-interface__tags .ui-chips {
405
+ @include font-2xs;
406
+
407
+ height: 36px;
408
+ padding-inline: 12px;
409
+ color: $video-info-tag-text;
410
+ background-color: $video-info-tag-bg;
411
+ border-radius: $radius-md;
412
+
413
+ @include from-br(sm) {
414
+ @include font-sm;
415
+ }
416
+
417
+ @include hover(color, background-color) {
418
+ color: $video-info-tag-text--hover;
419
+ background-color: $video-info-tag-bg--hover;
420
+ }
421
+ }
317
422
  </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,13 @@ 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: теги и поиски из групп (без повторов). Категории — из `categories`: в группы попадают только те, у кого есть раздел
187
+ const tagGroupChips = computed((): IChipsItem[] => [...new Map((props.data.tagGroups ?? [])
188
+ .filter(group => group.type !== 'category')
189
+ .flatMap(group => group.items.map(item => convertTagGroupItemToChips(group.type, item)))
190
+ .map(chip => [`${chip.prefix}/${chip.value}`, chip])).values()]);
189
191
 
190
192
  const categoriesChips = computed((): IChipsItem[] => props.data.categories && props.data.categories?.map((item) => ({
191
193
  title: item.title,
@@ -197,13 +199,11 @@ const categoriesChips = computed((): IChipsItem[] => props.data.categories && pr
197
199
 
198
200
  const categoriesTitles = computed<string[]>(() => props.data.categories && props.data.categories?.map(item => item.title?.replace(/\s+/g, '').toLowerCase() ?? ''));
199
201
 
200
- const filterTagChips = computed(() => {
201
- if (categoriesTitles.value && categoriesTitles.value.length > 0) {
202
- return tagChips.value.filter(tagItem => !categoriesTitles.value.includes(tagItem.title.replace(/\s+/g, '').toLowerCase()));
203
- } else {
204
- return tagChips.value;
205
- }
206
- });
202
+ const withoutCategoryTitles = (chips: IChipsItem[]) => (categoriesTitles.value?.length
203
+ ? chips.filter(chip => !categoriesTitles.value.includes(chip.title.replace(/\s+/g, '').toLowerCase()))
204
+ : chips);
205
+
206
+ const filterTagChips = computed(() => withoutCategoryTitles(tagChips.value));
207
207
 
208
208
  const recentSearches = computed(() => {
209
209
  if (props.recent && typeof props.recent === 'object' && Object.keys(props.recent).length === 0) {
@@ -223,6 +223,8 @@ const topChips = computed(() => {
223
223
  title: item,
224
224
  prefix: 'search',
225
225
  } as IChipsItem));
226
+ } else if (tagGroupChips.value.length) {
227
+ return [...categoriesChips.value || [], ...withoutCategoryTitles(tagGroupChips.value)];
226
228
  } else if (categoriesChips.value || filterTagChips.value) {
227
229
  return [...categoriesChips.value || [], ...filterTagChips.value || []];
228
230
  }
@@ -27,7 +27,7 @@ export async function useCategoryPageData() {
27
27
 
28
28
  /**
29
29
  * Логика страницы категории поверх `useCategoryPageData()`: мета и 404, хлебные крошки, номер страницы,
30
- * карточка инфо и чипсы связанных фраз. Синхронная — вызывать в setup после `await useCategoryPageData()`.
30
+ * карточка инфо и чипсы связанных фраз (по убыванию `score`, не больше `appConfig.categoryRelatedSearchLimit`). Синхронная — вызывать в setup после `await useCategoryPageData()`.
31
31
  *
32
32
  * Мета по умолчанию — из API (`metaTitle` / `metaDescription`, без них — название), h1 — `header` или название.
33
33
  * `metaKeys: true` — мета и h1 через `useMeta` по ключам `meta.<page>.<sort>.*` (`{text}` — название, с анти-дублем
@@ -87,14 +87,20 @@ export function useCategoryPage(
87
87
  miniCardTitle: categoryInfo.value?.title,
88
88
  } as ICardInfo));
89
89
 
90
+ const relatedSearchLimit = useAppConfig().categoryRelatedSearchLimit;
91
+
90
92
  const relatedSearch = computed<IChipsItem[]>(() => {
91
93
  if (DISALLOWED_SLUGS.some(item => slug.value.includes(item))) return [];
92
94
 
93
- return relatedData.value?.items?.map(item => ({
95
+ const items = [...(relatedData.value?.items || [])]
96
+ .sort((a, b) => Number(b.score) - Number(a.score))
97
+ .slice(0, relatedSearchLimit ?? undefined);
98
+
99
+ return items.map(item => ({
94
100
  title: item.query,
95
101
  value: item.query,
96
102
  prefix: 'search',
97
- })) || [];
103
+ }));
98
104
  });
99
105
 
100
106
  const relatedCategories = computed(() => categoryInfo.value?.related);
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "Alle anzeigen",
129
129
  "all_categories": "Alphabetisch A-Z",
130
130
  "related_searches": "Ähnliche Suchen",
131
+ "tags": "Tags",
131
132
  "related_models": "Ähnliche Models",
132
133
  "other_names": "Andere Namen",
133
134
  "popular_models": "Beliebte Models",
@@ -207,6 +207,7 @@
207
207
  "show_all_models": "Show all",
208
208
  "all_categories": "Alphabetical A-Z",
209
209
  "related_searches": "Related searches",
210
+ "tags": "Tags",
210
211
  "related_models": "Related models",
211
212
  "other_names": "Other names",
212
213
  "popular_models": "Popular models",
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "Ver todos",
129
129
  "all_categories": "Alfabético A-Z",
130
130
  "related_searches": "Búsquedas relacionadas",
131
+ "tags": "Etiquetas",
131
132
  "related_models": "Modelos relacionados",
132
133
  "other_names": "Otros nombres",
133
134
  "popular_models": "Modelos populares",
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "Voir tout",
129
129
  "all_categories": "Alphabétique A-Z",
130
130
  "related_searches": "Recherches associées",
131
+ "tags": "Tags",
131
132
  "related_models": "Modèles associés",
132
133
  "other_names": "Autres noms",
133
134
  "popular_models": "Modèles populaires",
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "Vedi tutti",
129
129
  "all_categories": "Alfabetico A-Z",
130
130
  "related_searches": "Ricerche correlate",
131
+ "tags": "Tag",
131
132
  "related_models": "Modelli correlati",
132
133
  "other_names": "Altri nomi",
133
134
  "popular_models": "Modelli popolari",
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "すべて見る",
129
129
  "all_categories": "アルファベット A-Z",
130
130
  "related_searches": "関連検索",
131
+ "tags": "タグ",
131
132
  "related_models": "関連モデル",
132
133
  "other_names": "他の名前",
133
134
  "popular_models": "人気モデル",
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "전체 보기",
129
129
  "all_categories": "알파벳 A-Z",
130
130
  "related_searches": "관련 검색",
131
+ "tags": "태그",
131
132
  "related_models": "관련 모델",
132
133
  "other_names": "다른 이름",
133
134
  "popular_models": "인기 모델",
@@ -149,6 +149,7 @@
149
149
  "show_all_models": "Показать все",
150
150
  "all_categories": "По алфавиту А-Я",
151
151
  "related_searches": "Похожие запросы",
152
+ "tags": "Теги",
152
153
  "related_models": "Похожие модели",
153
154
  "other_names": "Другие имена",
154
155
  "popular_models": "Популярные модели",
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "查看全部",
129
129
  "all_categories": "字母 A-Z",
130
130
  "related_searches": "相关搜索",
131
+ "tags": "标签",
131
132
  "related_models": "相关模特",
132
133
  "other_names": "其他名称",
133
134
  "popular_models": "热门模特",
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.1055",
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,30 @@ describe('cleanVideoData', () => {
170
171
  expect(result.dislikes).toBe(0);
171
172
  });
172
173
  });
174
+
175
+ const raw = (entities: unknown) => ({ tags: ['anal'], entities } as unknown as IRawVideoData);
176
+
177
+ describe('cleanVideoData — группы под плеером', () => {
178
+ it('v1: групп нет, теги — как пришли', () => {
179
+ const data = cleanVideoData(raw(undefined));
180
+ expect(data.tags).toEqual(['anal']);
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: 'tag', title: 'tag', order: 0, items: [
190
+ { name: 'rimming', title: 'rimming', order: 2 },
191
+ { name: 'anal', title: 'anal', order: 1 },
192
+ { name: 'doggy', title: 'doggy', order: 3 },
193
+ ] },
194
+ ]));
195
+
196
+ expect(data.tagGroups.map(group => group.name)).toEqual(['tag', 'positions']);
197
+ expect(data.tagGroups[0].items.map(item => item.name)).toEqual(['anal', 'rimming', 'doggy']);
198
+ expect(data.tags).toEqual(['anal']);
199
+ });
200
+ });
@@ -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,24 @@ 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 отдаёт группы в `entities`. Группы сортируются по `order` (и элементы внутри, если он задан);
72
+ // пустые и неизвестного типа (ссылку не построить) отбрасываются.
73
+ const cleanTagGroups = (raw: IRawVideoData['entities']): IVideoTagGroup[] => (Array.isArray(raw) ? raw : [])
74
+ .filter(group => group?.title && TAG_GROUP_TYPES.has(group.type) && Array.isArray(group.items))
75
+ .map(group => ({
76
+ type: group.type,
77
+ name: group.name || '',
78
+ title: group.title,
79
+ order: group.order ?? 0,
80
+ items: group.items.filter(item => item?.title && item.name).sort(byOrder),
81
+ }))
82
+ .filter(group => group.items.length > 0)
83
+ .sort(byOrder);
84
+
67
85
  /** Нормализует сырые данные видео к типу IVideoData. */
68
86
  export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData => ({
69
87
  guid: data.guid || '',
@@ -75,6 +93,7 @@ export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData
75
93
  likes: data.likes || 0,
76
94
  id: data.id || '',
77
95
  tags: data.tags || [],
96
+ tagGroups: cleanTagGroups(data.entities),
78
97
  models: cleanVideoModels(data.models, theme),
79
98
  categories: data.categories || [],
80
99
  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,7 +13,8 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
13
13
  'user-agent': requestHeaders[ 'user-agent' ] ?? '',
14
14
  };
15
15
 
16
- const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v1/video/id/${id}`, event);
16
+ // v2 ищет ролик по `url` — это id или слаг, бекенд разбирает сам; группы под плеером — в `entities`
17
+ const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v2/video/url/${id}`, event);
17
18
  const modelsUrl = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-video-id/${id}`, event);
18
19
 
19
20
  const [videoRaw, modelsRaw] = await Promise.all([
@@ -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 = withDemoFallbacks(videoRaw.data as IRawVideoData);
28
29
  const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
29
30
 
30
31
  return cleanVideoData(
@@ -12,7 +12,7 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
12
12
  ...requestHeaders,
13
13
  'user-agent': requestHeaders[ 'user-agent' ] ?? '',
14
14
  };
15
- const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v1/video/url/${url}`, event);
15
+ const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v2/video/url/${url}`, event);
16
16
  const modelsUrl = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-video-url/${url}`, event);
17
17
 
18
18
  const [videoRaw, modelsRaw] = await Promise.all([
@@ -23,7 +23,7 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
23
23
  apiFetch(modelsUrl, { method: 'GET', headers: clientHeaders }).catch(() => null),
24
24
  ]);
25
25
 
26
- const videoData = videoRaw.data as IRawVideoData;
26
+ const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
27
27
  const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
28
28
 
29
29
  return cleanVideoData(
@@ -0,0 +1,26 @@
1
+ import type { IRawVideoData } from '../../types';
2
+
3
+ // заглушка нарочно lorem — сразу видно, что не настоящее описание
4
+ const MOCK_DESCRIPTION = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et '
5
+ + 'dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo '
6
+ + 'consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.';
7
+
8
+ /**
9
+ * shortcut: демо блока под плеером, пока у роликов нет описаний и в админке не заведены разделы категорий — без описания
10
+ * подставляется lorem, без разделов категории ролика идут одной группой «Categories». Удалить, когда данные появятся.
11
+ */
12
+ export function withDemoFallbacks(video: IRawVideoData): IRawVideoData {
13
+ if (!video) return video;
14
+
15
+ const entities = video.entities ?? [];
16
+ const hasSections = entities.some(group => group.type === 'category');
17
+ const categories = (video.categories ?? []).map(({ name, title, icon }) => ({ name, title, icon }));
18
+
19
+ return {
20
+ ...video,
21
+ description: video.description || MOCK_DESCRIPTION,
22
+ entities: hasSections || !categories.length
23
+ ? entities
24
+ : [{ type: 'category' as const, name: 'categories', title: 'Categories', order: -1, items: categories }, ...entities],
25
+ };
26
+ }
@@ -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;
@@ -19,6 +19,8 @@ export interface IRawVideoData {
19
19
  popularity: number; // unused
20
20
  id: string;
21
21
  tags: string[];
22
+ /** Только v2: категории по разделам, затем теги и поисковые фразы (`tag`/`search` — с `title`, равным типу). */
23
+ entities?: IVideoTagGroup[];
22
24
  models: {
23
25
  guid: string;
24
26
  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;
@@ -46,6 +67,8 @@ export interface IVideoData {
46
67
  dislikes: number;
47
68
  id: string;
48
69
  tags: string[];
70
+ /** Группы под плеером (раскрытое описание); у ответа v1 групп нет — пусто. */
71
+ tagGroups: IVideoTagGroup[];
49
72
  models: {
50
73
  guid: string;
51
74
  title: string;