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 +30 -0
- package/app.config.ts +2 -0
- package/assets/scss/base/_loading.scss +13 -1
- package/assets/scss/themes/dark/_theme.scss +8 -0
- package/assets/scss/themes/light/_theme.scss +8 -0
- package/components/card/channel.vue +3 -3
- package/components/card/video/index.vue +8 -8
- package/components/player/interface.vue +118 -13
- package/components/view/video.vue +15 -13
- package/composables/page/use-category-page.ts +9 -3
- package/i18n/locales/de-DE.json +1 -0
- package/i18n/locales/en-US.json +1 -0
- package/i18n/locales/es-ES.json +1 -0
- package/i18n/locales/fr-FR.json +1 -0
- package/i18n/locales/it-IT.json +1 -0
- package/i18n/locales/ja-JP.json +1 -0
- package/i18n/locales/ko-KR.json +1 -0
- package/i18n/locales/ru-RU.json +1 -0
- package/i18n/locales/zh-CN.json +1 -0
- package/package.json +1 -1
- package/runtime/index.ts +1 -0
- package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +28 -0
- package/runtime/utils/cleaners/clean-video-data.ts +20 -1
- package/runtime/utils/converters/convert-tag-group-item-to-chips.ts +14 -0
- package/server/api/videos/get-video-by-id/[id].get.ts +3 -2
- package/server/api/videos/get-video-by-url/[url].get.ts +2 -2
- package/server/utils/video-demo-mock.ts +26 -0
- package/types/raw/raw-video-data.d.ts +3 -1
- package/types/video-data.d.ts +23 -0
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
|
-
|
|
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="
|
|
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,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
|
-
<
|
|
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
|
+
<!-- у узлов тегов и поисков бекенд кладёт в 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="
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
* карточка инфо и чипсы связанных
|
|
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
|
-
|
|
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);
|
package/i18n/locales/de-DE.json
CHANGED
|
@@ -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",
|
package/i18n/locales/en-US.json
CHANGED
|
@@ -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",
|
package/i18n/locales/es-ES.json
CHANGED
|
@@ -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",
|
package/i18n/locales/fr-FR.json
CHANGED
|
@@ -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",
|
package/i18n/locales/it-IT.json
CHANGED
|
@@ -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",
|
package/i18n/locales/ja-JP.json
CHANGED
package/i18n/locales/ko-KR.json
CHANGED
package/i18n/locales/ru-RU.json
CHANGED
|
@@ -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": "Популярные модели",
|
package/i18n/locales/zh-CN.json
CHANGED
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,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
|
-
|
|
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}/
|
|
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;
|
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;
|
|
@@ -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;
|