itube-specs 0.0.985 → 0.0.988
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/components/card/model-mini.vue +8 -6
- package/components/card/model.vue +7 -7
- package/components/card/round.vue +10 -5
- package/components/card/video/index.vue +21 -77
- package/components/info/main.vue +12 -7
- package/components/search/item.vue +5 -9
- package/components/search/result.vue +2 -0
- package/components/section/swiper-cards.vue +3 -0
- package/components/ui/chips.vue +2 -1
- package/components/view/model-videos.vue +2 -1
- package/components/view/models.vue +2 -1
- package/components/view/video.vue +5 -1
- package/components/view/videos.vue +2 -1
- package/package.json +1 -1
- package/pages/categories/[slug].vue +2 -1
- package/pages/channels/[channel].vue +2 -1
- package/pages/following.vue +2 -1
- package/pages/search/[slug]/index.vue +2 -1
- package/runtime/index.ts +2 -1
- package/runtime/utils/__tests__/video-data-add-model-portraits.test.ts +47 -0
- package/runtime/utils/cleaners/__tests__/clean-suggest-models.test.ts +40 -0
- package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +46 -1
- package/runtime/utils/cleaners/clean-suggest-models.ts +23 -0
- package/runtime/utils/cleaners/clean-video-data.ts +19 -2
- package/runtime/utils/video-data-add-model-portraits.ts +27 -0
- package/server/api/search/multi-suggest.get.ts +10 -4
- package/server/api/videos/get-video-by-id/[id].get.ts +6 -2
- package/server/api/videos/get-video-by-url/[url].get.ts +6 -2
- package/types/chips-item.d.ts +2 -0
- package/types/multi-suggest.d.ts +20 -2
- package/types/raw/raw-video-data.d.ts +3 -0
- package/types/search-top-models.d.ts +3 -6
- package/types/video-data.d.ts +3 -0
- package/runtime/utils/__tests__/video-data-add-model-icon.test.ts +0 -32
- package/runtime/utils/video-data-add-model-icon.ts +0 -25
|
@@ -52,16 +52,18 @@ const props = defineProps<{
|
|
|
52
52
|
card: IModelCard
|
|
53
53
|
}>();
|
|
54
54
|
|
|
55
|
-
const fallbackImage = '/img/model-placeholder.webp';
|
|
55
|
+
const fallbackImage = '/img/model-placeholder-square.webp';
|
|
56
|
+
const fallbackAvifImage = '/img/model-placeholder-square.avif';
|
|
56
57
|
const imageError = ref(false);
|
|
57
58
|
|
|
58
59
|
/** Аватар 48px круглый — нужен квадратный кроп, вертикальный портрет сюда не годится. */
|
|
59
|
-
const
|
|
60
|
-
if (imageError.value) return fallbackImage;
|
|
61
|
-
return props.card.primaryImageSmallUrl || fallbackImage;
|
|
62
|
-
});
|
|
60
|
+
const isFallback = computed(() => imageError.value || !props.card.primaryImageSmallUrl);
|
|
63
61
|
|
|
64
|
-
const
|
|
62
|
+
const currentImage = computed(() => (isFallback.value ? fallbackImage : props.card.primaryImageSmallUrl));
|
|
63
|
+
|
|
64
|
+
const currentAvifSrc = computed(() => (
|
|
65
|
+
isFallback.value ? fallbackAvifImage : props.card.primaryImageSmallAvifUrl
|
|
66
|
+
));
|
|
65
67
|
</script>
|
|
66
68
|
|
|
67
69
|
<style lang="scss">
|
|
@@ -98,16 +98,16 @@ const { t } = useI18n();
|
|
|
98
98
|
*/
|
|
99
99
|
const SIZES = 'auto, (max-width: 449px) 50vw, (max-width: 1385px) 215px, 250px';
|
|
100
100
|
|
|
101
|
-
const fallbackImage = '/img/model-placeholder.webp';
|
|
101
|
+
const fallbackImage = '/img/model-placeholder-portrait.webp';
|
|
102
|
+
const fallbackAvifImage = '/img/model-placeholder-portrait.avif';
|
|
102
103
|
const imageError = ref(false);
|
|
103
104
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
});
|
|
105
|
+
const isFallback = computed(() => imageError.value || !props.card.primaryImageUrl);
|
|
106
|
+
|
|
107
|
+
const currentImage = computed(() => (isFallback.value ? fallbackImage : props.card.primaryImageUrl));
|
|
108
108
|
|
|
109
|
-
/** На заглушке
|
|
110
|
-
const currentAvifSrc = computed(() => (
|
|
109
|
+
/** На заглушке подменяем и avif-ветку — иначе браузер продолжит тянуть битый кандидат. */
|
|
110
|
+
const currentAvifSrc = computed(() => (isFallback.value ? fallbackAvifImage : props.card.primaryImageAvifUrl));
|
|
111
111
|
|
|
112
112
|
function onImageError() {
|
|
113
113
|
imageError.value = true;
|
|
@@ -49,6 +49,8 @@ const props = withDefaults(defineProps<{
|
|
|
49
49
|
loading?: 'eager' | 'lazy'
|
|
50
50
|
priority?: boolean
|
|
51
51
|
placeholderAvatar?: string
|
|
52
|
+
/** Та же заглушка в avif. У канальной (svg) пары нет — тогда проп не передают. */
|
|
53
|
+
placeholderAvatarAvif?: string
|
|
52
54
|
/** Только аватар и название — без счётчика роликов (страница подписок) */
|
|
53
55
|
withoutCount?: boolean
|
|
54
56
|
}>(), {
|
|
@@ -64,12 +66,15 @@ const SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
|
|
|
64
66
|
const fallbackImage = props.placeholderAvatar;
|
|
65
67
|
const imageError = ref(false);
|
|
66
68
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
const isFallback = computed(() => imageError.value || !props.card.primaryImageUrl);
|
|
70
|
+
|
|
71
|
+
const currentImage = computed(() => (
|
|
72
|
+
(isFallback.value ? fallbackImage : props.card.primaryImageUrl) || ''
|
|
73
|
+
));
|
|
71
74
|
|
|
72
|
-
const currentAvifSrc = computed(() => (
|
|
75
|
+
const currentAvifSrc = computed(() => (
|
|
76
|
+
isFallback.value ? props.placeholderAvatarAvif : props.card.primaryImageAvifUrl
|
|
77
|
+
));
|
|
73
78
|
|
|
74
79
|
function onImageError() {
|
|
75
80
|
imageError.value = true;
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
{'--mini': mini},
|
|
6
6
|
{'--compact': compact},
|
|
7
7
|
{'--avatar': card.channelAvatar},
|
|
8
|
-
{'--no-channel': isChannelPage},
|
|
9
8
|
{'--progress': hasProgress},
|
|
10
9
|
{'--favorite': isFavoriteCard},
|
|
11
10
|
{'--tags-open': isTagsOpen},
|
|
@@ -95,30 +94,10 @@
|
|
|
95
94
|
class="card-video__title _truncate"
|
|
96
95
|
>{{ card.title }}
|
|
97
96
|
</NuxtLink>
|
|
98
|
-
<component
|
|
99
|
-
v-if="!mini && !compact && !history && card.channelName"
|
|
100
|
-
:is="component"
|
|
101
|
-
:to="channelLink"
|
|
102
|
-
class="card-video__channel-wrapper"
|
|
103
|
-
>
|
|
104
|
-
<span
|
|
105
|
-
:data-nosnippet="!card.channelName || null"
|
|
106
|
-
class="card-video__channel-title"
|
|
107
|
-
>
|
|
108
|
-
<template v-if="card.channelName">
|
|
109
|
-
{{ card.channelName }}
|
|
110
|
-
</template>
|
|
111
|
-
</span>
|
|
112
|
-
</component>
|
|
113
97
|
<p
|
|
114
98
|
v-if="history"
|
|
115
99
|
class="card-video__meta"
|
|
116
100
|
>
|
|
117
|
-
<NuxtLink
|
|
118
|
-
v-if="card.channelName"
|
|
119
|
-
:to="channelLink"
|
|
120
|
-
class="card-video__channel-title"
|
|
121
|
-
>{{ card.channelName }}</NuxtLink>
|
|
122
101
|
<span
|
|
123
102
|
:class="{'--remaining': remaining}"
|
|
124
103
|
class="card-video__watched"
|
|
@@ -189,9 +168,6 @@ const component = computed(() => props.card.channelName ? NuxtLinkComponent : 'd
|
|
|
189
168
|
|
|
190
169
|
const { generateLink } = useGenerateLink();
|
|
191
170
|
|
|
192
|
-
const pageName = useGetPureRouteName();
|
|
193
|
-
const isChannelPage = computed(() => pageName === 'channels-channel');
|
|
194
|
-
|
|
195
171
|
const previewId = useId();
|
|
196
172
|
const { activePreviewId, startPreviewByHover, stopPreviewByHover, startPreviewByTouch } = useVideoPreview();
|
|
197
173
|
|
|
@@ -383,7 +359,6 @@ $dropdown-column: 18px;
|
|
|
383
359
|
grid-column: 2;
|
|
384
360
|
}
|
|
385
361
|
|
|
386
|
-
.--avatar .card-video__channel-wrapper,
|
|
387
362
|
.--avatar .card-video__meta {
|
|
388
363
|
grid-column: 2;
|
|
389
364
|
}
|
|
@@ -398,25 +373,26 @@ $dropdown-column: 18px;
|
|
|
398
373
|
grid-column: 2 / -1;
|
|
399
374
|
}
|
|
400
375
|
|
|
376
|
+
/* Кружок канала — только на десктопе: на мобиле карточка отдаёт всю ширину заголовку */
|
|
401
377
|
@include to-br(sm) {
|
|
402
|
-
|
|
403
|
-
.--no-channel .card-video__channel-wrapper {
|
|
378
|
+
.card-video .card-video__logo-wrapper {
|
|
404
379
|
display: none;
|
|
405
380
|
}
|
|
406
381
|
|
|
407
|
-
|
|
382
|
+
.card-video .card-video__wrapper {
|
|
408
383
|
grid-template-columns: minmax(0, 1fr) $dropdown-column;
|
|
409
384
|
}
|
|
410
385
|
|
|
411
|
-
|
|
386
|
+
.card-video .card-video__title,
|
|
387
|
+
.card-video .card-video__meta {
|
|
412
388
|
grid-column: 1;
|
|
413
389
|
}
|
|
414
390
|
|
|
415
|
-
|
|
391
|
+
.card-video .card-video__tags {
|
|
416
392
|
grid-column: 1 / -1;
|
|
417
393
|
}
|
|
418
394
|
|
|
419
|
-
|
|
395
|
+
.card-video .card-video__dropdown {
|
|
420
396
|
grid-column: 2;
|
|
421
397
|
grid-row: 1;
|
|
422
398
|
}
|
|
@@ -426,7 +402,7 @@ $dropdown-column: 18px;
|
|
|
426
402
|
grid-template-columns: minmax(0, 1fr) $dropdown-column;
|
|
427
403
|
}
|
|
428
404
|
|
|
429
|
-
/* Без
|
|
405
|
+
/* Без тегов вторая строка грида не нужна — иначе кружок и «троеточие» растягиваются. */
|
|
430
406
|
.--compact .card-video__logo-wrapper,
|
|
431
407
|
.--compact .card-video__dropdown {
|
|
432
408
|
grid-row: 1;
|
|
@@ -455,23 +431,7 @@ $dropdown-column: 18px;
|
|
|
455
431
|
}
|
|
456
432
|
}
|
|
457
433
|
|
|
458
|
-
|
|
459
|
-
@include font-xs;
|
|
460
|
-
|
|
461
|
-
font-weight: 400;
|
|
462
|
-
display: grid;
|
|
463
|
-
grid-row: 2;
|
|
464
|
-
grid-template-columns: auto 1fr;
|
|
465
|
-
align-items: center;
|
|
466
|
-
column-gap: 4px;
|
|
467
|
-
margin-top: 6px;
|
|
468
|
-
|
|
469
|
-
@include to-cq($layout-breakpoint) {
|
|
470
|
-
grid-column: 1;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/* Мета-строка истории: «канал · когда смотрели / сколько осталось» на месте строки канала */
|
|
434
|
+
/* Мета-строка истории: когда смотрели / сколько осталось */
|
|
475
435
|
.card-video__meta {
|
|
476
436
|
@include font-xs;
|
|
477
437
|
|
|
@@ -488,19 +448,6 @@ $dropdown-column: 18px;
|
|
|
488
448
|
}
|
|
489
449
|
}
|
|
490
450
|
|
|
491
|
-
/* На узкой карточке (две колонки на телефоне) «канал · время» в строку не встают, а нешрикуемое
|
|
492
|
-
время задаёт колонке min-content и разносит грид — оставляем только время просмотра */
|
|
493
|
-
@include to-cq($layout-breakpoint) {
|
|
494
|
-
.card-video__meta .card-video__channel-title {
|
|
495
|
-
display: none;
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
.card-video__meta .card-video__channel-title + .card-video__watched::before {
|
|
500
|
-
content: '·';
|
|
501
|
-
margin-inline: 6px;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
451
|
.card-video__watched {
|
|
505
452
|
display: flex;
|
|
506
453
|
align-items: center;
|
|
@@ -519,29 +466,24 @@ $dropdown-column: 18px;
|
|
|
519
466
|
border-radius: 50%;
|
|
520
467
|
}
|
|
521
468
|
|
|
469
|
+
/* Кружок раскидан на обе строки: его 36px набираются заголовком плюс плашками, иначе под
|
|
470
|
+
однострочным заголовком остаётся пустой хвост высотой в остаток кружка */
|
|
522
471
|
.card-video__logo-wrapper {
|
|
523
472
|
grid-row: 1/3;
|
|
524
473
|
grid-column: 1;
|
|
525
474
|
}
|
|
526
475
|
|
|
527
|
-
|
|
528
|
-
.card-
|
|
476
|
+
/* У видео без моделей плашек нет вовсе — второй строки тоже, кружку спан не по чему набирать */
|
|
477
|
+
.card-video__wrapper:not(:has(.card-video__tags, .card-video__meta)) .card-video__logo-wrapper {
|
|
478
|
+
grid-row: 1;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
.card-video__logo-wrapper[href] {
|
|
529
482
|
@include hover(opacity) {
|
|
530
483
|
opacity: 0.9;
|
|
531
484
|
}
|
|
532
485
|
}
|
|
533
486
|
|
|
534
|
-
.card-video__channel-title {
|
|
535
|
-
@include font-xs;
|
|
536
|
-
|
|
537
|
-
font-weight: 400;
|
|
538
|
-
color: $video-card-secondary;
|
|
539
|
-
overflow: hidden;
|
|
540
|
-
white-space: nowrap;
|
|
541
|
-
text-overflow: ellipsis;
|
|
542
|
-
max-width: 100%;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
487
|
.card-video.--mini {
|
|
546
488
|
row-gap: 8px;
|
|
547
489
|
}
|
|
@@ -561,13 +503,15 @@ $dropdown-column: 18px;
|
|
|
561
503
|
z-index: $z-index-dropdown;
|
|
562
504
|
}
|
|
563
505
|
|
|
564
|
-
/* Ряд плашек лежит ниже «троеточия» — колонка под меню в этом ряду свободна, отдаём её
|
|
506
|
+
/* Ряд плашек лежит ниже «троеточия» — колонка под меню в этом ряду свободна, отдаём её плашкам.
|
|
507
|
+
Строка задана явно: на авторазмещении плашки уезжали в третью строку, оставляя пустую вторую */
|
|
565
508
|
.card-video__tags {
|
|
509
|
+
grid-row: 2;
|
|
566
510
|
grid-column: 1 / -1;
|
|
567
511
|
|
|
568
512
|
@include from-cq($layout-breakpoint) {
|
|
569
513
|
grid-column: 2 / -1;
|
|
570
|
-
margin-top:
|
|
514
|
+
margin-top: 6px;
|
|
571
515
|
}
|
|
572
516
|
}
|
|
573
517
|
|
package/components/info/main.vue
CHANGED
|
@@ -204,20 +204,25 @@ function onFollowClick() {
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
const placeholders: Record<string, string> = {
|
|
207
|
-
model: '/img/model-placeholder.webp',
|
|
207
|
+
model: '/img/model-placeholder-square.webp',
|
|
208
208
|
channel: '/img/logo-placeholder.svg',
|
|
209
209
|
};
|
|
210
|
-
|
|
210
|
+
/** У канала заглушка — svg, avif-пары у неё нет. */
|
|
211
|
+
const avifPlaceholders: Record<string, string | undefined> = {
|
|
212
|
+
model: '/img/model-placeholder-square.avif',
|
|
213
|
+
};
|
|
214
|
+
const placeholderType = computed(() => props.type || 'channel');
|
|
215
|
+
const placeholder = computed(() => placeholders[ placeholderType.value ]);
|
|
211
216
|
const imageError = ref(false);
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
return props.card.img || placeholder.value;
|
|
215
|
-
});
|
|
217
|
+
const isFallback = computed(() => imageError.value || !props.card.img);
|
|
218
|
+
const currentImage = computed(() => (isFallback.value ? placeholder.value : props.card.img));
|
|
216
219
|
|
|
217
220
|
/** `.info-main__img` — 80px до sm, 96px дальше. `auto` впереди — см. `UiImg`. */
|
|
218
221
|
const IMG_SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
|
|
219
222
|
|
|
220
|
-
const currentAvifSrc = computed(() => (
|
|
223
|
+
const currentAvifSrc = computed(() => (
|
|
224
|
+
isFallback.value ? avifPlaceholders[ placeholderType.value ] : props.card.imgAvifUrl
|
|
225
|
+
));
|
|
221
226
|
|
|
222
227
|
function getParameter(name: string) {
|
|
223
228
|
return props.card.parameters?.find(p => p.name === name)?.values?.[ 0 ]?.name;
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
<script setup lang="ts">
|
|
33
33
|
import type { ISearchTopModelsType } from '../../types';
|
|
34
|
-
import { convertString
|
|
34
|
+
import { convertString } from '../../runtime';
|
|
35
35
|
import DOMPurify from 'dompurify';
|
|
36
36
|
|
|
37
37
|
const props = defineProps<{
|
|
@@ -42,14 +42,10 @@ const props = defineProps<{
|
|
|
42
42
|
}>();
|
|
43
43
|
|
|
44
44
|
/** Иконка 32px — квадратный кроп по лицу, если он есть в ответе. */
|
|
45
|
-
const icon = computed(() => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
webp: square?.webp || '/img/model-placeholder.webp',
|
|
50
|
-
avif: square?.avif,
|
|
51
|
-
};
|
|
52
|
-
});
|
|
45
|
+
const icon = computed(() => ({
|
|
46
|
+
webp: props.item?.imageUrl || '/img/model-placeholder-square.webp',
|
|
47
|
+
avif: props.item?.imageUrl ? props.item.imageAvifUrl : '/img/model-placeholder-square.avif',
|
|
48
|
+
}));
|
|
53
49
|
|
|
54
50
|
const title = computed(() => props.item?.title?.replace(/<[^>]*>/g, ''));
|
|
55
51
|
const { generateLink } = useGenerateLink();
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
<CardRound
|
|
39
39
|
:card="item"
|
|
40
40
|
:placeholder-avatar="placeholderAvatar"
|
|
41
|
+
:placeholder-avatar-avif="placeholderAvatarAvif"
|
|
41
42
|
:without-count="withoutCount"
|
|
42
43
|
class="section-swiper-cards__card"
|
|
43
44
|
/>
|
|
@@ -79,6 +80,8 @@ withDefaults(defineProps<{
|
|
|
79
80
|
/** Вариант заголовка — см. `UiSectionTitle` */
|
|
80
81
|
variant?: 'page' | 'section'
|
|
81
82
|
placeholderAvatar?: string
|
|
83
|
+
/** Та же заглушка в avif — см. `CardRound` */
|
|
84
|
+
placeholderAvatarAvif?: string
|
|
82
85
|
/** Карточки только с аватаром и названием, без счётчика роликов */
|
|
83
86
|
withoutCount?: boolean
|
|
84
87
|
}>(), {
|
package/components/ui/chips.vue
CHANGED
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
<UiImg
|
|
25
25
|
v-if="item.image"
|
|
26
26
|
:src="item.image"
|
|
27
|
+
:avif-src="item.imageAvif"
|
|
27
28
|
:alt="name"
|
|
28
29
|
:class="`--${imageFit}`"
|
|
29
30
|
:width="imageBox.width"
|
|
30
31
|
:height="imageBox.height"
|
|
31
32
|
class="ui-chips__img"
|
|
32
|
-
sizes="
|
|
33
|
+
sizes="auto, 24px"
|
|
33
34
|
/>
|
|
34
35
|
{{ name }}
|
|
35
36
|
<UiIcon
|
|
@@ -92,7 +92,8 @@
|
|
|
92
92
|
v-if="similarModels && similarModels.length > 0"
|
|
93
93
|
:items="modelsRoundCards"
|
|
94
94
|
:title="$t('similar_models')"
|
|
95
|
-
placeholder-avatar="/img/model-placeholder.webp"
|
|
95
|
+
placeholder-avatar="/img/model-placeholder-square.webp"
|
|
96
|
+
placeholder-avatar-avif="/img/model-placeholder-square.avif"
|
|
96
97
|
class="view-model-videos__other-models"
|
|
97
98
|
hydrate-on-visible
|
|
98
99
|
/>
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
v-if="topCards.length >= MINIMUM_TOP_THUMBS"
|
|
7
7
|
:items="topCards"
|
|
8
8
|
class="view-models__popular"
|
|
9
|
-
placeholder-avatar="/img/model-placeholder.webp"
|
|
9
|
+
placeholder-avatar="/img/model-placeholder-square.webp"
|
|
10
|
+
placeholder-avatar-avif="/img/model-placeholder-square.avif"
|
|
10
11
|
only-grid
|
|
11
12
|
/>
|
|
12
13
|
|
|
@@ -91,7 +91,8 @@
|
|
|
91
91
|
:items="modelsRoundCards"
|
|
92
92
|
:title="$t('related_models')"
|
|
93
93
|
class="view-video__popular-models"
|
|
94
|
-
placeholder-avatar="/img/model-placeholder.webp"
|
|
94
|
+
placeholder-avatar="/img/model-placeholder-square.webp"
|
|
95
|
+
placeholder-avatar-avif="/img/model-placeholder-square.avif"
|
|
95
96
|
hydrate-on-visible
|
|
96
97
|
/>
|
|
97
98
|
|
|
@@ -203,11 +204,14 @@ const topChips = computed(() => {
|
|
|
203
204
|
|
|
204
205
|
const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
|
|
205
206
|
|
|
207
|
+
/** Портрет в чипсе — квадратный кроп по лицу; своего у видео нет, его подмешивает BFF. */
|
|
206
208
|
function getModelChips(model: IVideoData['models'][number]): IChipsItem {
|
|
207
209
|
return {
|
|
208
210
|
prefix: '/models',
|
|
209
211
|
value: model.title,
|
|
210
212
|
title: model.title,
|
|
213
|
+
image: model.imageUrl || '/img/model-placeholder-square.webp',
|
|
214
|
+
imageAvif: model.imageUrl ? model.imageAvifUrl : '/img/model-placeholder-square.avif',
|
|
211
215
|
};
|
|
212
216
|
}
|
|
213
217
|
</script>
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
:all-link-text="$t('show_all_models')"
|
|
50
50
|
:header-link-text-short="$t('all_models')"
|
|
51
51
|
class="view-videos__popular-models"
|
|
52
|
-
placeholder-avatar="/img/model-placeholder.webp"
|
|
52
|
+
placeholder-avatar="/img/model-placeholder-square.webp"
|
|
53
|
+
placeholder-avatar-avif="/img/model-placeholder-square.avif"
|
|
53
54
|
hydrate-on-visible
|
|
54
55
|
/>
|
|
55
56
|
</GridVideos>
|
package/package.json
CHANGED
|
@@ -55,7 +55,8 @@
|
|
|
55
55
|
:items="modelsRoundCards"
|
|
56
56
|
:title="$t('category_top_models_title', {name: title})"
|
|
57
57
|
class="category__models"
|
|
58
|
-
placeholder-avatar="/img/model-placeholder.webp"
|
|
58
|
+
placeholder-avatar="/img/model-placeholder-square.webp"
|
|
59
|
+
placeholder-avatar-avif="/img/model-placeholder-square.avif"
|
|
59
60
|
/>
|
|
60
61
|
</template>
|
|
61
62
|
</GridVideos>
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
:items="modelsRoundCards"
|
|
53
53
|
:title="t('channels_popular_models')"
|
|
54
54
|
class="channel__videos-models"
|
|
55
|
-
placeholder-avatar="/img/model-placeholder.webp"
|
|
55
|
+
placeholder-avatar="/img/model-placeholder-square.webp"
|
|
56
|
+
placeholder-avatar-avif="/img/model-placeholder-square.avif"
|
|
56
57
|
hydrate-on-visible
|
|
57
58
|
/>
|
|
58
59
|
</template>
|
package/pages/following.vue
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
all-link="/library/models"
|
|
19
19
|
icon="user"
|
|
20
20
|
variant="section"
|
|
21
|
-
placeholder-avatar="/img/model-placeholder.webp"
|
|
21
|
+
placeholder-avatar="/img/model-placeholder-square.webp"
|
|
22
|
+
placeholder-avatar-avif="/img/model-placeholder-square.avif"
|
|
22
23
|
class="following__section"
|
|
23
24
|
without-count
|
|
24
25
|
/>
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
<template #default="item">
|
|
50
50
|
<CardRound
|
|
51
51
|
:card="item"
|
|
52
|
-
placeholder-avatar="/img/model-placeholder.webp"
|
|
52
|
+
placeholder-avatar="/img/model-placeholder-square.webp"
|
|
53
|
+
placeholder-avatar-avif="/img/model-placeholder-square.avif"
|
|
53
54
|
/>
|
|
54
55
|
</template>
|
|
55
56
|
<template
|
package/runtime/index.ts
CHANGED
|
@@ -37,7 +37,8 @@ export * from './utils/cleaners/clean-profile-data';
|
|
|
37
37
|
export * from './utils/cleaners/clean-video-data';
|
|
38
38
|
export * from './utils/cleaners/clean-video-card';
|
|
39
39
|
export * from './utils/cleaners/clean-model-info';
|
|
40
|
-
export * from './utils/
|
|
40
|
+
export * from './utils/cleaners/clean-suggest-models';
|
|
41
|
+
export * from './utils/video-data-add-model-portraits';
|
|
41
42
|
export * from './utils/converters/convert-categories-to-footer';
|
|
42
43
|
export * from './utils/converters/convert-categories-to-chips';
|
|
43
44
|
export * from './utils/converters/convert-model-card-to-chips';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { videoDataAddModelPortraits } from '../video-data-add-model-portraits';
|
|
3
|
+
|
|
4
|
+
const portraits = {
|
|
5
|
+
dark: {
|
|
6
|
+
tall: { width: 300, height: 450, webp: 'a-tall.webp', avif: 'a-tall.avif' },
|
|
7
|
+
square: { width: 200, height: 200, webp: 'a-square.webp', avif: 'a-square.avif' },
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('videoDataAddModelPortraits', () => {
|
|
12
|
+
it('добавляет portraits к matching модели', () => {
|
|
13
|
+
const videoData = {
|
|
14
|
+
models: [
|
|
15
|
+
{ guid: 'm-1', title: 'Model A' },
|
|
16
|
+
{ guid: 'm-2', title: 'Model B' },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
const modelsData = [
|
|
20
|
+
{ guid: 'm-1', portraits },
|
|
21
|
+
];
|
|
22
|
+
const result = videoDataAddModelPortraits(videoData as any, modelsData as any);
|
|
23
|
+
expect(result.models[0].portraits).toEqual(portraits);
|
|
24
|
+
expect(result.models[1].portraits).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('карточка без портретов ничего не добавляет', () => {
|
|
28
|
+
const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
|
|
29
|
+
const modelsData = [{ guid: 'm-1', primary_image_url: 'https://img.com/a.jpg' }];
|
|
30
|
+
const result = videoDataAddModelPortraits(videoData as any, modelsData as any);
|
|
31
|
+
expect(result.models[0].portraits).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('не мутирует исходный объект', () => {
|
|
35
|
+
const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
|
|
36
|
+
const modelsData = [{ guid: 'm-1', portraits }];
|
|
37
|
+
const result = videoDataAddModelPortraits(videoData as any, modelsData as any);
|
|
38
|
+
expect(result).not.toBe(videoData);
|
|
39
|
+
expect((videoData.models[0] as any).portraits).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('пустой массив моделей', () => {
|
|
43
|
+
const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
|
|
44
|
+
const result = videoDataAddModelPortraits(videoData as any, []);
|
|
45
|
+
expect(result.models[0].portraits).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { cleanSuggestModels } from '../clean-suggest-models';
|
|
3
|
+
|
|
4
|
+
const portraits = {
|
|
5
|
+
dark: {
|
|
6
|
+
tall: { width: 640, height: 960, webp: 'tall.webp', avif: 'tall.avif' },
|
|
7
|
+
square: { width: 256, height: 256, webp: 'square.webp', avif: 'square.avif' },
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('cleanSuggestModels', () => {
|
|
12
|
+
it('берёт квадратный кроп, вертикальный игнорирует', () => {
|
|
13
|
+
const result = cleanSuggestModels([
|
|
14
|
+
{ title: 'Alex Ink', videosCount: 5, portraits },
|
|
15
|
+
] as any);
|
|
16
|
+
|
|
17
|
+
expect(result).toEqual([
|
|
18
|
+
{ title: 'Alex Ink', videosCount: 5, imageUrl: 'square.webp', imageAvifUrl: 'square.avif' },
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('модель без портретов остаётся без картинки', () => {
|
|
23
|
+
const result = cleanSuggestModels([{ title: 'Alex Rim', videosCount: 1 }] as any);
|
|
24
|
+
|
|
25
|
+
expect(result).toEqual([{ title: 'Alex Rim', videosCount: 1 }]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('только вертикальный портрет — картинки нет', () => {
|
|
29
|
+
const result = cleanSuggestModels([
|
|
30
|
+
{ title: 'A', videosCount: 2, portraits: { dark: { tall: portraits.dark.tall } } },
|
|
31
|
+
] as any);
|
|
32
|
+
|
|
33
|
+
expect(result).toEqual([{ title: 'A', videosCount: 2 }]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('пустой список остаётся null, чтобы не всплыл таб «models»', () => {
|
|
37
|
+
expect(cleanSuggestModels(null)).toBeNull();
|
|
38
|
+
expect(cleanSuggestModels(undefined as any)).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -13,7 +13,7 @@ describe('cleanVideoData', () => {
|
|
|
13
13
|
likes: 100,
|
|
14
14
|
id: 'id-1',
|
|
15
15
|
tags: 'tag1',
|
|
16
|
-
models: '
|
|
16
|
+
models: [{ guid: 'm-1', title: 'Model A' }],
|
|
17
17
|
categories: 'cat1',
|
|
18
18
|
actions: { liked: true },
|
|
19
19
|
channel: { name: 'Channel', avatar_url: 'https://img.com/ch.jpg' },
|
|
@@ -37,6 +37,51 @@ describe('cleanVideoData', () => {
|
|
|
37
37
|
expect(result.videoUrl).toBe('/stream');
|
|
38
38
|
expect(result.isDeleted).toBe(false);
|
|
39
39
|
expect(result.thumbNum).toBe(5);
|
|
40
|
+
expect(result.models).toEqual([{ guid: 'm-1', title: 'Model A' }]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('модели: квадратный кроп портрета в imageUrl/imageAvifUrl', () => {
|
|
44
|
+
const raw = {
|
|
45
|
+
models: [
|
|
46
|
+
{
|
|
47
|
+
guid: 'm-1',
|
|
48
|
+
title: 'Model A',
|
|
49
|
+
portraits: {
|
|
50
|
+
dark: {
|
|
51
|
+
tall: { width: 300, height: 450, webp: 'tall.webp', avif: 'tall.avif' },
|
|
52
|
+
square: { width: 200, height: 200, webp: 'square.webp', avif: 'square.avif' },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{ guid: 'm-2', title: 'Model B' },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
const result = cleanVideoData(raw as any);
|
|
60
|
+
expect(result.models[0]).toEqual({
|
|
61
|
+
guid: 'm-1',
|
|
62
|
+
title: 'Model A',
|
|
63
|
+
imageUrl: 'square.webp',
|
|
64
|
+
imageAvifUrl: 'square.avif',
|
|
65
|
+
});
|
|
66
|
+
expect(result.models[1]).toEqual({ guid: 'm-2', title: 'Model B' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('модели: только вертикальный портрет — картинки нет', () => {
|
|
70
|
+
const raw = {
|
|
71
|
+
models: [
|
|
72
|
+
{
|
|
73
|
+
guid: 'm-1',
|
|
74
|
+
title: 'Model A',
|
|
75
|
+
portraits: { dark: { tall: { width: 300, height: 450, webp: 'tall.webp', avif: 'tall.avif' } } },
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
const result = cleanVideoData(raw as any);
|
|
80
|
+
expect(result.models[0]).toEqual({ guid: 'm-1', title: 'Model A' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('моделей нет вовсе', () => {
|
|
84
|
+
expect(cleanVideoData({} as any).models).toEqual([]);
|
|
40
85
|
});
|
|
41
86
|
|
|
42
87
|
it('таймлайны: конец периода из бекендного end_duration', () => {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ISuggestModel, IRawSuggestModel } from '../../../types';
|
|
2
|
+
import { pickPortraitVariant } from '../pick-portrait-variant';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Приводит модели из multi-suggest к готовым ссылкам на портрет: подсказке нужен квадратный
|
|
6
|
+
* кроп по лицу, вертикальный `tall` в иконку 32px не годится. Пустой список бек отдаёт как
|
|
7
|
+
* `null` — сохраняем как есть, иначе в поиске появится пустой таб «models».
|
|
8
|
+
* @param models - модели из ответа multi-suggest, уже приведённого к camelCase
|
|
9
|
+
* @param theme - тема сайта: ею выбирается вариант портрета
|
|
10
|
+
*/
|
|
11
|
+
export const cleanSuggestModels = (models: IRawSuggestModel[] | null, theme?: string): ISuggestModel[] | null => {
|
|
12
|
+
if (!Array.isArray(models)) return models ?? null;
|
|
13
|
+
|
|
14
|
+
return models.map((model) => {
|
|
15
|
+
const square = pickPortraitVariant(model.portraits, theme)?.square;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
title: model.title,
|
|
19
|
+
videosCount: model.videosCount,
|
|
20
|
+
...(square?.webp ? { imageUrl: square.webp, imageAvifUrl: square.avif } : {}),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IRawVideoData, IVideoData, ISceneGroup, ITimelineGroup } from '../../../types';
|
|
2
|
+
import { pickPortraitVariant } from '../pick-portrait-variant';
|
|
2
3
|
|
|
3
4
|
// «locations» → «Locations», «living_room» → «Living room».
|
|
4
5
|
const humanize = (value?: string | null): string => {
|
|
@@ -45,8 +46,24 @@ const cleanHotspots = (raw: string[] | null): number[] | null => {
|
|
|
45
46
|
return values.length ? values : null;
|
|
46
47
|
};
|
|
47
48
|
|
|
49
|
+
// Моделям видео нужен только квадратный кроп по лицу — он идёт в чипсу под заголовком.
|
|
50
|
+
// Вертикальный `tall` сюда не годится, как и в круглые аватары.
|
|
51
|
+
const cleanVideoModels = (models: IRawVideoData['models'], theme?: string): IVideoData['models'] => {
|
|
52
|
+
if (!Array.isArray(models)) return [];
|
|
53
|
+
|
|
54
|
+
return models.map((model) => {
|
|
55
|
+
const square = pickPortraitVariant(model.portraits, theme)?.square;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
guid: model.guid,
|
|
59
|
+
title: model.title,
|
|
60
|
+
...(square?.webp ? { imageUrl: square.webp, imageAvifUrl: square.avif } : {}),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
48
65
|
/** Нормализует сырые данные видео к типу IVideoData. */
|
|
49
|
-
export const cleanVideoData = (data: IRawVideoData): IVideoData => ({
|
|
66
|
+
export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData => ({
|
|
50
67
|
guid: data.guid || '',
|
|
51
68
|
md5: data.md5 || '',
|
|
52
69
|
duration: data.duration || 0,
|
|
@@ -56,7 +73,7 @@ export const cleanVideoData = (data: IRawVideoData): IVideoData => ({
|
|
|
56
73
|
likes: data.likes || 0,
|
|
57
74
|
id: data.id || '',
|
|
58
75
|
tags: data.tags || [],
|
|
59
|
-
models: data.models
|
|
76
|
+
models: cleanVideoModels(data.models, theme),
|
|
60
77
|
categories: data.categories || [],
|
|
61
78
|
actions: data.actions || null,
|
|
62
79
|
channelName: data.channel?.name || '',
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IRawModelCard, IRawVideoData } from '../../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Подмешивает portraits из карточек моделей в каждую модель videoData.models по совпадению guid.
|
|
5
|
+
* Ответ видео своих портретов не отдаёт — их приносит параллельный запрос
|
|
6
|
+
* `models/list-by-video-id|url`, а вариант выбирает уже клинер.
|
|
7
|
+
* @param videoData - сырые данные видео
|
|
8
|
+
* @param modelsData - массив карточек моделей с portraits
|
|
9
|
+
*/
|
|
10
|
+
export function videoDataAddModelPortraits(videoData: IRawVideoData, modelsData: Array<IRawModelCard>): IRawVideoData {
|
|
11
|
+
const transformedData = { ...videoData };
|
|
12
|
+
|
|
13
|
+
transformedData.models = transformedData.models?.map((model) => {
|
|
14
|
+
const modelData = modelsData.find((item) => item.guid === model.guid);
|
|
15
|
+
|
|
16
|
+
if (modelData?.portraits) {
|
|
17
|
+
return {
|
|
18
|
+
...model,
|
|
19
|
+
portraits: modelData.portraits,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return model;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return transformedData;
|
|
27
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { getUrlWithProxiedParams, convertSnakeKeysToCamel } from '../../../runtime';
|
|
2
|
-
import type { IMultiSuggest } from '../../../types';
|
|
1
|
+
import { getUrlWithProxiedParams, convertSnakeKeysToCamel, cleanSuggestModels } from '../../../runtime';
|
|
2
|
+
import type { IMultiSuggest, IRawMultiSuggest } from '../../../types';
|
|
3
3
|
|
|
4
|
-
export default defineEventHandler(async (event) => {
|
|
4
|
+
export default defineEventHandler(async (event): Promise<IMultiSuggest> => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const url = getUrlWithProxiedParams(`${apiUrl}/v2/search/multi-suggest`, event);
|
|
7
8
|
|
|
@@ -10,5 +11,10 @@ export default defineEventHandler(async (event) => {
|
|
|
10
11
|
headers: getRequestHeaders(event) as HeadersInit,
|
|
11
12
|
});
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
const data = convertSnakeKeysToCamel<IRawMultiSuggest>(rawData.data);
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
...data,
|
|
18
|
+
models: cleanSuggestModels(data?.models, theme),
|
|
19
|
+
};
|
|
14
20
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
|
|
2
|
-
import { cleanVideoData,
|
|
2
|
+
import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const id = getRouterParam(event, 'id');
|
|
7
8
|
|
|
@@ -26,5 +27,8 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
|
26
27
|
const videoData = videoRaw.data as IRawVideoData;
|
|
27
28
|
const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
|
|
28
29
|
|
|
29
|
-
return cleanVideoData(
|
|
30
|
+
return cleanVideoData(
|
|
31
|
+
videoData?.models ? videoDataAddModelPortraits(videoData, modelsData?.items || []) : videoData,
|
|
32
|
+
theme,
|
|
33
|
+
);
|
|
30
34
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { cleanVideoData,
|
|
1
|
+
import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
|
|
2
2
|
import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const url = getRouterParam(event, 'url');
|
|
7
8
|
|
|
@@ -25,5 +26,8 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
|
25
26
|
const videoData = videoRaw.data as IRawVideoData;
|
|
26
27
|
const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
|
|
27
28
|
|
|
28
|
-
return cleanVideoData(
|
|
29
|
+
return cleanVideoData(
|
|
30
|
+
videoData?.models ? videoDataAddModelPortraits(videoData, modelsData?.items || []) : videoData,
|
|
31
|
+
theme,
|
|
32
|
+
);
|
|
29
33
|
});
|
package/types/chips-item.d.ts
CHANGED
package/types/multi-suggest.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { IRawPortraits } from './raw/raw-portraits';
|
|
2
|
+
|
|
1
3
|
interface AboutItem {
|
|
2
4
|
key: string;
|
|
3
5
|
sizeCache: string;
|
|
@@ -62,9 +64,19 @@ interface Category {
|
|
|
62
64
|
videosCount: number;
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
|
|
67
|
+
/** Модель, как её отдаёт multi-suggest: с портретами, но до выбора варианта. */
|
|
68
|
+
export interface IRawSuggestModel {
|
|
66
69
|
title: string;
|
|
67
70
|
videosCount: number;
|
|
71
|
+
portraits?: IRawPortraits;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Та же модель после `cleanSuggestModels`: квадратный кроп уже выбран. */
|
|
75
|
+
export interface ISuggestModel {
|
|
76
|
+
title: string;
|
|
77
|
+
videosCount: number;
|
|
78
|
+
imageUrl?: string;
|
|
79
|
+
imageAvifUrl?: string;
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
interface Stream {
|
|
@@ -96,10 +108,16 @@ interface StripcashModel {
|
|
|
96
108
|
viewersCount: number;
|
|
97
109
|
}
|
|
98
110
|
|
|
111
|
+
/** Ответ multi-suggest после BFF: модели уже прошли `cleanSuggestModels`. */
|
|
99
112
|
export interface IMultiSuggest {
|
|
100
113
|
awempireModels: AwempireModel[];
|
|
101
114
|
categories: Category[];
|
|
102
|
-
models:
|
|
115
|
+
models: ISuggestModel[] | null;
|
|
103
116
|
searchPhrases: string[];
|
|
104
117
|
stripcashModels: StripcashModel[];
|
|
105
118
|
}
|
|
119
|
+
|
|
120
|
+
/** Тот же ответ до чистки моделей — с ним работает хендлер. */
|
|
121
|
+
export interface IRawMultiSuggest extends Omit<IMultiSuggest, 'models'> {
|
|
122
|
+
models: IRawSuggestModel[] | null;
|
|
123
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IRawThumbUrls } from './raw-thumbs-urls';
|
|
2
|
+
import type { IRawPortraits } from './raw-portraits';
|
|
2
3
|
import type { ITimelineGroup } from '../video-data';
|
|
3
4
|
|
|
4
5
|
export interface IRawVideoData {
|
|
@@ -21,6 +22,8 @@ export interface IRawVideoData {
|
|
|
21
22
|
models: {
|
|
22
23
|
guid: string;
|
|
23
24
|
title: string;
|
|
25
|
+
/** Своего поля у видео нет — портреты подмешивает BFF из карточек `models/list-by-video-*`. */
|
|
26
|
+
portraits?: IRawPortraits;
|
|
24
27
|
}[];
|
|
25
28
|
categories: {
|
|
26
29
|
icon?: string,
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import type { IRawPortraits } from './raw/raw-portraits';
|
|
2
|
-
|
|
3
1
|
export interface ISearchTopModelsType {
|
|
4
2
|
videosCount?: number
|
|
5
3
|
name: string
|
|
6
4
|
title: string
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
portraits?: IRawPortraits
|
|
5
|
+
/** Квадратный кроп портрета: и multi-suggest, и `models/top` отдают его уже выбранным. */
|
|
6
|
+
imageUrl?: string
|
|
7
|
+
imageAvifUrl?: string
|
|
11
8
|
}
|
package/types/video-data.d.ts
CHANGED
|
@@ -49,6 +49,9 @@ export interface IVideoData {
|
|
|
49
49
|
models: {
|
|
50
50
|
guid: string;
|
|
51
51
|
title: string;
|
|
52
|
+
/** Квадратный кроп портрета под чипсу модели: его подмешивает BFF, в ответе видео его нет. */
|
|
53
|
+
imageUrl?: string;
|
|
54
|
+
imageAvifUrl?: string;
|
|
52
55
|
}[];
|
|
53
56
|
categories: {
|
|
54
57
|
name: string;
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { videoDataAddModelIcon } from '../video-data-add-model-icon';
|
|
3
|
-
|
|
4
|
-
describe('videoDataAddModelIcon', () => {
|
|
5
|
-
it('добавляет icon к matching модели', () => {
|
|
6
|
-
const videoData = {
|
|
7
|
-
models: [
|
|
8
|
-
{ guid: 'm-1', title: 'Model A' },
|
|
9
|
-
{ guid: 'm-2', title: 'Model B' },
|
|
10
|
-
],
|
|
11
|
-
};
|
|
12
|
-
const modelsData = [
|
|
13
|
-
{ guid: 'm-1', primary_image_url: 'https://img.com/a.jpg' },
|
|
14
|
-
];
|
|
15
|
-
const result = videoDataAddModelIcon(videoData as any, modelsData as any);
|
|
16
|
-
expect(result.models[0].icon).toBe('https://img.com/a.jpg');
|
|
17
|
-
expect(result.models[1].icon).toBeUndefined();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('не мутирует исходный объект', () => {
|
|
21
|
-
const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
|
|
22
|
-
const modelsData = [{ guid: 'm-1', primary_image_url: 'url' }];
|
|
23
|
-
const result = videoDataAddModelIcon(videoData as any, modelsData as any);
|
|
24
|
-
expect(result).not.toBe(videoData);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('пустой массив моделей', () => {
|
|
28
|
-
const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
|
|
29
|
-
const result = videoDataAddModelIcon(videoData as any, []);
|
|
30
|
-
expect(result.models[0].icon).toBeUndefined();
|
|
31
|
-
});
|
|
32
|
-
});
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import type { IRawModelCard, IRawVideoData } from '../../types';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Добавляет поле icon из modelsData в каждую модель videoData.models по совпадению guid.
|
|
5
|
-
* @param videoData - сырые данные видео
|
|
6
|
-
* @param modelsData - массив карточек моделей с primary_image_url
|
|
7
|
-
*/
|
|
8
|
-
export function videoDataAddModelIcon(videoData: IRawVideoData, modelsData: Array<IRawModelCard>): IRawVideoData {
|
|
9
|
-
const transformedData = { ...videoData };
|
|
10
|
-
|
|
11
|
-
transformedData.models = transformedData.models?.map((model) => {
|
|
12
|
-
const modelData = modelsData.find((item) => item.guid === model.guid);
|
|
13
|
-
|
|
14
|
-
if (modelData) {
|
|
15
|
-
return {
|
|
16
|
-
...model,
|
|
17
|
-
icon: modelData.primary_image_url,
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
return model;
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
return transformedData;
|
|
25
|
-
}
|