itube-specs 0.0.966 → 0.0.968
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 +4 -1
- package/components/card/model.vue +12 -2
- package/components/card/round.vue +8 -1
- package/components/info/main.vue +9 -1
- package/components/player/aside-videos.vue +49 -24
- package/components/search/item.vue +2 -1
- package/components/ui/img.vue +58 -19
- package/components/view/video.vue +2 -2
- package/components/view/videos.vue +6 -1
- package/composables/fetch/use-fetch-vector-videos-search.ts +37 -0
- package/composables/use-test-model-images.ts +54 -0
- package/package.json +1 -1
- package/pages/models/[slug]/index.vue +4 -2
- package/pages/models/index.vue +6 -13
- package/pages/search/[slug]/index.vue +5 -1
- package/pages/search/[slug]/videos.vue +9 -3
- package/runtime/constants/async-data.ts +1 -0
- package/runtime/constants/model-image-size.ts +6 -0
- package/runtime/index.ts +2 -0
- package/runtime/utils/__tests__/build-srcset.test.ts +24 -0
- package/runtime/utils/build-srcset.ts +14 -0
- package/runtime/utils/cleaners/__tests__/clean-model-card.test.ts +27 -0
- package/runtime/utils/cleaners/clean-model-card.ts +9 -0
- package/runtime/utils/converters/__tests__/convert-model-to-round-card.test.ts +26 -0
- package/runtime/utils/converters/convert-model-to-round-card.ts +1 -0
- package/server/api/videos/vector-search.post.ts +30 -0
- package/services/api/videos.service.ts +28 -0
- package/stubs/library-follows.ts +15 -15
- package/types/card-info.d.ts +3 -0
- package/types/index.d.ts +2 -0
- package/types/model-card.d.ts +4 -0
- package/types/model-image-urls.d.ts +7 -0
- package/types/raw/raw-model-card.d.ts +2 -0
- package/types/raw/raw-model-image-urls.d.ts +8 -0
- package/types/round-card.d.ts +4 -0
- package/types/search-top-models.d.ts +4 -0
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
<div class="card-model-mini">
|
|
4
4
|
<UiImg
|
|
5
5
|
:src="currentImage"
|
|
6
|
+
:image-urls="currentImageUrls"
|
|
6
7
|
:alt="card.title"
|
|
7
8
|
class="card-model-mini__img"
|
|
8
9
|
width="48"
|
|
9
10
|
height="48"
|
|
10
|
-
sizes="48px"
|
|
11
|
+
sizes="auto, 48px"
|
|
11
12
|
@error="imageError = true"
|
|
12
13
|
/>
|
|
13
14
|
<div class="card-model-mini__wrapper">
|
|
@@ -58,6 +59,8 @@ const currentImage = computed(() => {
|
|
|
58
59
|
if (imageError.value) return fallbackImage;
|
|
59
60
|
return props.card.primaryImageUrl || props.card.thumbUrl || fallbackImage;
|
|
60
61
|
});
|
|
62
|
+
|
|
63
|
+
const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
|
|
61
64
|
</script>
|
|
62
65
|
|
|
63
66
|
<style lang="scss">
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
<div class="card-model__thumb">
|
|
9
9
|
<UiImg
|
|
10
10
|
:src="currentImage"
|
|
11
|
+
:image-urls="currentImageUrls"
|
|
11
12
|
:loading="loading"
|
|
12
13
|
:alt="card.title"
|
|
13
14
|
:priority="priority"
|
|
14
|
-
:sizes="
|
|
15
|
+
:sizes="SIZES"
|
|
15
16
|
class="card-model__img"
|
|
16
17
|
width="240"
|
|
17
18
|
height="320"
|
|
@@ -77,7 +78,6 @@
|
|
|
77
78
|
|
|
78
79
|
<script setup lang="ts">
|
|
79
80
|
import { convertString, formatDate } from '../../runtime';
|
|
80
|
-
import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
|
|
81
81
|
import type { IModelCard } from '../../types';
|
|
82
82
|
|
|
83
83
|
const props = withDefaults(defineProps<{
|
|
@@ -91,6 +91,13 @@ const props = withDefaults(defineProps<{
|
|
|
91
91
|
const { generateLink } = useGenerateLink();
|
|
92
92
|
const { t } = useI18n();
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Пороги — колоночная лестница `.grid-models` (2 → 3 с 640px → … → 6 с 1280px), а не `$brkpnts`.
|
|
96
|
+
* Слот от 640px и выше держится в 206–249px, поэтому один `250px` покрывает всю правую часть.
|
|
97
|
+
* `auto` впереди — см. `UiImg`.
|
|
98
|
+
*/
|
|
99
|
+
const SIZES = 'auto, (max-width: 639px) 50vw, 250px';
|
|
100
|
+
|
|
94
101
|
const fallbackImage = '/img/model-placeholder.webp';
|
|
95
102
|
const imageError = ref(false);
|
|
96
103
|
|
|
@@ -99,6 +106,9 @@ const currentImage = computed(() => {
|
|
|
99
106
|
return props.card.primaryImageUrl || props.card.thumbUrl || fallbackImage;
|
|
100
107
|
});
|
|
101
108
|
|
|
109
|
+
/** На заглушке ладдер сбрасываем — иначе браузер продолжит тянуть битые кандидаты. */
|
|
110
|
+
const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
|
|
111
|
+
|
|
102
112
|
function onImageError() {
|
|
103
113
|
imageError.value = true;
|
|
104
114
|
}
|
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
>
|
|
7
7
|
<UiImg
|
|
8
8
|
:src="currentImage"
|
|
9
|
+
:image-urls="currentImageUrls"
|
|
9
10
|
:loading="loading"
|
|
10
11
|
:alt="card.title"
|
|
11
12
|
:priority="priority"
|
|
13
|
+
:sizes="SIZES"
|
|
12
14
|
class="card-round__img"
|
|
13
15
|
width="96"
|
|
14
16
|
height="96"
|
|
15
|
-
sizes="96px"
|
|
16
17
|
@error="onImageError"
|
|
17
18
|
/>
|
|
18
19
|
<span
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
</template>
|
|
41
42
|
|
|
42
43
|
<script setup lang="ts">
|
|
44
|
+
import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
|
|
43
45
|
import type { IRoundCard } from '../../types';
|
|
44
46
|
|
|
45
47
|
const props = withDefaults(defineProps<{
|
|
@@ -56,6 +58,9 @@ const props = withDefaults(defineProps<{
|
|
|
56
58
|
const { t } = useI18n();
|
|
57
59
|
const { generateLink } = useGenerateLink();
|
|
58
60
|
|
|
61
|
+
/** `--card-round-size`: 80px до sm, 96px дальше. `auto` впереди — см. `UiImg`. */
|
|
62
|
+
const SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
|
|
63
|
+
|
|
59
64
|
const fallbackImage = props.placeholderAvatar;
|
|
60
65
|
const imageError = ref(false);
|
|
61
66
|
|
|
@@ -64,6 +69,8 @@ const currentImage = computed(() => {
|
|
|
64
69
|
return props.card.primaryImageUrl || fallbackImage || '';
|
|
65
70
|
});
|
|
66
71
|
|
|
72
|
+
const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
|
|
73
|
+
|
|
67
74
|
function onImageError() {
|
|
68
75
|
imageError.value = true;
|
|
69
76
|
}
|
package/components/info/main.vue
CHANGED
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
<UiImg
|
|
16
16
|
v-if="card.img"
|
|
17
17
|
:src="currentImage"
|
|
18
|
+
:image-urls="currentImageUrls"
|
|
18
19
|
:alt="title"
|
|
20
|
+
:sizes="IMG_SIZES"
|
|
19
21
|
class="info-main__img"
|
|
20
22
|
width="80"
|
|
21
23
|
height="80"
|
|
22
|
-
sizes="80px"
|
|
23
24
|
@error="imageError = true"
|
|
24
25
|
/>
|
|
25
26
|
|
|
@@ -157,6 +158,7 @@ import type {
|
|
|
157
158
|
IChannelCard,
|
|
158
159
|
ICategoryCard
|
|
159
160
|
} from '../../types';
|
|
161
|
+
import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
|
|
160
162
|
import { AuthorizationApiService } from '../../services/api/authorization.service';
|
|
161
163
|
import { groupParametersByGroup } from '../../utils/info-main/group-parameters-by-group';
|
|
162
164
|
import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
|
|
@@ -210,6 +212,11 @@ const currentImage = computed(() => {
|
|
|
210
212
|
return props.card.img || placeholder.value;
|
|
211
213
|
});
|
|
212
214
|
|
|
215
|
+
/** `.info-main__img` — 80px до sm, 96px дальше. `auto` впереди — см. `UiImg`. */
|
|
216
|
+
const IMG_SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
|
|
217
|
+
|
|
218
|
+
const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.imgUrls));
|
|
219
|
+
|
|
213
220
|
function getParameter(name: string) {
|
|
214
221
|
return props.card.parameters?.find(p => p.name === name)?.values?.[ 0 ]?.name;
|
|
215
222
|
}
|
|
@@ -231,6 +238,7 @@ const shareModelCard = computed<IModelCard>(() => ({
|
|
|
231
238
|
title: props.card.title,
|
|
232
239
|
videosCount: props.card.videosCount,
|
|
233
240
|
thumbUrl: props.card.img || '',
|
|
241
|
+
primaryImageUrls: props.card.imgUrls,
|
|
234
242
|
guid: props.card.guid || '',
|
|
235
243
|
parameters: [],
|
|
236
244
|
country: shareCountry.value,
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
макете её нет, но формат живёт именно в этой колонке.
|
|
6
6
|
|
|
7
7
|
Если моделей у ролика нет (и рекламы тоже), колонка не пустует: показываем пару
|
|
8
|
-
рекомендаций, как было до макета.
|
|
8
|
+
рекомендаций, как было до макета.
|
|
9
|
+
|
|
10
|
+
Высота колонки ограничена высотой плеера, лишнее уходит в прокрутку внутри неё —
|
|
11
|
+
см. комментарий к стилям. -->
|
|
9
12
|
<template>
|
|
10
13
|
<div
|
|
11
14
|
:class="[
|
|
@@ -13,26 +16,28 @@
|
|
|
13
16
|
]"
|
|
14
17
|
class="player-aside-videos _from-sd"
|
|
15
18
|
>
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
<div class="player-aside-videos__inner">
|
|
20
|
+
<PlayerAsideModels
|
|
21
|
+
v-for="item in models || []"
|
|
22
|
+
:key="`aside-model-${item.slug}`"
|
|
23
|
+
:title="item.title"
|
|
24
|
+
:slug="item.slug"
|
|
25
|
+
:info="item.info"
|
|
26
|
+
:videos="item.videos?.items"
|
|
27
|
+
class="player-aside-videos__model"
|
|
28
|
+
/>
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
<AdvNtv v-if="featureFlags.AdsNTVEnabled" />
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
<template v-if="!models?.length && !featureFlags.AdsNTVEnabled">
|
|
33
|
+
<CardVideo
|
|
34
|
+
v-for="item in asideVideos?.items?.slice(0, 2)"
|
|
35
|
+
:key="`aside-video-${item.guid}`"
|
|
36
|
+
:card="item"
|
|
37
|
+
mini
|
|
38
|
+
/>
|
|
39
|
+
</template>
|
|
40
|
+
</div>
|
|
36
41
|
</div>
|
|
37
42
|
</template>
|
|
38
43
|
|
|
@@ -56,16 +61,36 @@ defineProps<{
|
|
|
56
61
|
</script>
|
|
57
62
|
|
|
58
63
|
<style lang="scss">
|
|
64
|
+
/**
|
|
65
|
+
* Высоту колонке задаёт плеер, а не её содержимое: блоков моделей может быть
|
|
66
|
+
* больше, чем помещается рядом с роликом, и тогда колонка растягивала строку
|
|
67
|
+
* сетки — плеер с описанием уезжали вверх от остальной страницы. Поэтому сама
|
|
68
|
+
* колонка держит только рамку (её собственная высота для сетки — ноль), а всё
|
|
69
|
+
* содержимое лежит абсолютным слоем поверх и прокручивается внутри. Скроллбар
|
|
70
|
+
* общий, из base/_global.scss.
|
|
71
|
+
*/
|
|
59
72
|
.player-aside-videos {
|
|
60
73
|
@include from-br(sd) {
|
|
61
|
-
|
|
62
|
-
gap: 32px;
|
|
63
|
-
grid-template-columns: 1fr;
|
|
74
|
+
position: relative;
|
|
64
75
|
width: 288px;
|
|
65
|
-
height: 100%;
|
|
66
76
|
grid-column: 2;
|
|
67
77
|
grid-row: 1;
|
|
68
|
-
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.player-aside-videos__inner {
|
|
82
|
+
display: grid;
|
|
83
|
+
gap: 32px;
|
|
84
|
+
grid-template-columns: 1fr;
|
|
85
|
+
align-content: start;
|
|
86
|
+
|
|
87
|
+
@include from-br(sd) {
|
|
88
|
+
position: absolute;
|
|
89
|
+
inset: 0;
|
|
90
|
+
overflow-y: auto;
|
|
91
|
+
|
|
92
|
+
// полоса прокрутки не должна наезжать на карточки
|
|
93
|
+
padding-right: 4px;
|
|
69
94
|
}
|
|
70
95
|
}
|
|
71
96
|
</style>
|
package/components/ui/img.vue
CHANGED
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
<template>
|
|
2
|
+
<!-- `<picture>` только под avif: обёртка сдвигает картинку на уровень ниже, а
|
|
3
|
+
`card/playlist.vue` и `playlist-mini.vue` целятся в свои UiImg через `:first-child`/`:nth-child(2)`
|
|
4
|
+
— внутри `<picture>` каждая из них стала бы первым ребёнком, и раскладка поехала бы. -->
|
|
5
|
+
<picture
|
|
6
|
+
v-if="avifSrcset"
|
|
7
|
+
class="ui-img"
|
|
8
|
+
>
|
|
9
|
+
<source
|
|
10
|
+
:srcset="avifSrcset"
|
|
11
|
+
:sizes="computedSizes"
|
|
12
|
+
type="image/avif"
|
|
13
|
+
>
|
|
14
|
+
<img
|
|
15
|
+
v-bind="imgAttrs"
|
|
16
|
+
@error="emit('error', $event)"
|
|
17
|
+
>
|
|
18
|
+
</picture>
|
|
2
19
|
<img
|
|
3
|
-
v-
|
|
4
|
-
|
|
5
|
-
:srcset="srcset"
|
|
6
|
-
:loading="loading"
|
|
7
|
-
:width="width"
|
|
8
|
-
:height="height"
|
|
9
|
-
:sizes="computedSizes"
|
|
10
|
-
:alt="alt"
|
|
11
|
-
:fetchpriority="priority ? 'high' : 'auto'"
|
|
20
|
+
v-else
|
|
21
|
+
v-bind="imgAttrs"
|
|
12
22
|
@error="emit('error', $event)"
|
|
13
23
|
>
|
|
14
24
|
</template>
|
|
15
25
|
|
|
16
26
|
<script setup lang="ts">
|
|
17
|
-
import type { IThumbUrls } from '../../types';
|
|
18
|
-
import { ThumbSize } from '../../runtime';
|
|
27
|
+
import type { IThumbUrls, IModelImageUrls } from '../../types';
|
|
28
|
+
import { ThumbSize, buildSrcset } from '../../runtime';
|
|
29
|
+
|
|
30
|
+
defineOptions({ inheritAttrs: false });
|
|
19
31
|
|
|
20
32
|
const props = withDefaults(defineProps<{
|
|
21
33
|
src?: string,
|
|
@@ -23,6 +35,8 @@ const props = withDefaults(defineProps<{
|
|
|
23
35
|
height: string | number,
|
|
24
36
|
sizes?: string,
|
|
25
37
|
urls?: IThumbUrls,
|
|
38
|
+
/** Ладдер с avif-веткой (портреты моделей). Старше `urls`. */
|
|
39
|
+
imageUrls?: IModelImageUrls,
|
|
26
40
|
alt?: string,
|
|
27
41
|
loading?: 'lazy' | 'eager',
|
|
28
42
|
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside',
|
|
@@ -32,7 +46,17 @@ const props = withDefaults(defineProps<{
|
|
|
32
46
|
fit: 'cover',
|
|
33
47
|
});
|
|
34
48
|
|
|
49
|
+
const attrs = useAttrs();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `sizes="auto"` точнее любого авторского списка, но молодой: Chrome 126, Firefox 150
|
|
53
|
+
* (апрель 2026), Safari 27 (сентябрь 2026). Где не поддержан — деградирует в `100vw`,
|
|
54
|
+
* и браузер берёт самый крупный кандидат. Для ладдеров, где это перерасход, вызывающий
|
|
55
|
+
* пишет `sizes` сам, начиная с `auto,` — остаток списка и есть фолбэк под Safari ≤26.
|
|
56
|
+
* Такой `sizes` отдаём как есть.
|
|
57
|
+
*/
|
|
35
58
|
const computedSizes = computed(() => {
|
|
59
|
+
if (props.sizes?.startsWith('auto')) return props.sizes;
|
|
36
60
|
if (props.loading === 'lazy') return 'auto';
|
|
37
61
|
return props.sizes;
|
|
38
62
|
});
|
|
@@ -43,16 +67,31 @@ const imgSrc = computed(() => {
|
|
|
43
67
|
return undefined;
|
|
44
68
|
});
|
|
45
69
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
70
|
+
const computedSrcset = computed(() => buildSrcset(props.imageUrls?.webp) || buildSrcset(props.urls?.webp));
|
|
71
|
+
|
|
72
|
+
const avifSrcset = computed(() => buildSrcset(props.imageUrls?.avif));
|
|
73
|
+
|
|
74
|
+
const imgAttrs = computed(() => ({
|
|
75
|
+
...attrs,
|
|
76
|
+
src: imgSrc.value,
|
|
77
|
+
srcset: computedSrcset.value,
|
|
78
|
+
loading: props.loading,
|
|
79
|
+
width: props.width,
|
|
80
|
+
height: props.height,
|
|
81
|
+
sizes: computedSizes.value,
|
|
82
|
+
alt: props.alt,
|
|
83
|
+
fetchpriority: props.priority ? 'high' : 'auto',
|
|
84
|
+
}));
|
|
54
85
|
|
|
55
86
|
const emit = defineEmits<{
|
|
56
87
|
(e: 'error', event: Event): void
|
|
57
88
|
}>();
|
|
58
89
|
</script>
|
|
90
|
+
|
|
91
|
+
<style lang="scss">
|
|
92
|
+
/* Обёртка не должна участвовать в раскладке: у потребителей вроде `.card-model__img` стоит
|
|
93
|
+
`height: 100%`, и оно должно меряться от того же родителя, что и раньше, а не от `<picture>`. */
|
|
94
|
+
.ui-img {
|
|
95
|
+
display: contents;
|
|
96
|
+
}
|
|
97
|
+
</style>
|
|
@@ -203,14 +203,14 @@ const topChips = computed(() => {
|
|
|
203
203
|
|
|
204
204
|
const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
|
|
205
205
|
|
|
206
|
-
const
|
|
206
|
+
const { portraitAt } = useTestModelImages();
|
|
207
207
|
|
|
208
208
|
function getModelChips(model: IVideoData['models'][number], index: number): IChipsItem {
|
|
209
209
|
return {
|
|
210
210
|
prefix: '/models',
|
|
211
211
|
value: model.title,
|
|
212
212
|
title: model.title,
|
|
213
|
-
image:
|
|
213
|
+
image: portraitAt(index).thumbUrl,
|
|
214
214
|
};
|
|
215
215
|
}
|
|
216
216
|
</script>
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
icon="video-camera"
|
|
12
12
|
/>
|
|
13
13
|
<LazySectionToolbar
|
|
14
|
+
v-if="!hideToolbar"
|
|
14
15
|
:sort-items="sortItems"
|
|
15
16
|
class="view-videos__options"
|
|
16
17
|
hydrate-on-idle
|
|
@@ -61,7 +62,7 @@
|
|
|
61
62
|
<UiEmptyState v-else/>
|
|
62
63
|
|
|
63
64
|
<LazyUiPagination
|
|
64
|
-
v-if="data"
|
|
65
|
+
v-if="data && !hidePagination"
|
|
65
66
|
arrow-icon
|
|
66
67
|
:key="`pagination-${data.allPages}`"
|
|
67
68
|
:total="data.allPages"
|
|
@@ -93,6 +94,10 @@ const props = defineProps<{
|
|
|
93
94
|
data: PaginatedResponse<IVideoCard> | undefined
|
|
94
95
|
status: string
|
|
95
96
|
hideCategories?: boolean
|
|
97
|
+
/** Выдача без сортировки и фильтров (векторный поиск) — тулбар не на что навесить */
|
|
98
|
+
hideToolbar?: boolean
|
|
99
|
+
/** Выдача одной страницей (векторный поиск) — страниц у эндпоинта нет */
|
|
100
|
+
hidePagination?: boolean
|
|
96
101
|
title?: string
|
|
97
102
|
footerText?: string
|
|
98
103
|
footerLinksLink?: string
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Language } from '../../runtime';
|
|
2
|
+
import { AsyncData } from '../../runtime';
|
|
3
|
+
import type { IVideoCard, PaginatedResponse } from '../../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ролики для страниц поиска через векторную близость. У эндпоинта нет ни страниц, ни сортировки,
|
|
7
|
+
* ни фильтров — только limit, поэтому запрашивается один топ по релевантности, а query-параметры
|
|
8
|
+
* роута на запрос не влияют.
|
|
9
|
+
*/
|
|
10
|
+
export const useFetchVectorVideosSearch = async (
|
|
11
|
+
apiMethod: (...args: any[]) => Promise<PaginatedResponse<IVideoCard>>,
|
|
12
|
+
{ limit: limitOption }: { limit?: { mobile?: number, desktop?: number } } = {}
|
|
13
|
+
) => {
|
|
14
|
+
const lang = useLang() as Language;
|
|
15
|
+
const mobile = !!useState<boolean>('isMobile').value;
|
|
16
|
+
const limit = (mobile ? limitOption?.mobile : limitOption?.desktop) ?? (mobile ? 12 : 48);
|
|
17
|
+
const slug = useSlug();
|
|
18
|
+
const key = AsyncData.VectorVideosSearch;
|
|
19
|
+
const stateKey = computed(() => `data-${key}-${slug.value}-${limit}-${lang}`);
|
|
20
|
+
|
|
21
|
+
const { data, status, error } = await useAsyncData<PaginatedResponse<IVideoCard>>(
|
|
22
|
+
stateKey.value,
|
|
23
|
+
() => {
|
|
24
|
+
if (!slug.value) return Promise.resolve(null as unknown as PaginatedResponse<IVideoCard>);
|
|
25
|
+
|
|
26
|
+
return useApiFetcher<PaginatedResponse<IVideoCard>>(
|
|
27
|
+
stateKey.value,
|
|
28
|
+
apiMethod,
|
|
29
|
+
limit,
|
|
30
|
+
lang,
|
|
31
|
+
slug.value
|
|
32
|
+
)();
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return { data, status, error };
|
|
37
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { IModelImageUrls } from '../types';
|
|
2
|
+
import { ModelImageSize } from '../runtime';
|
|
3
|
+
|
|
4
|
+
export interface ITestModelPortrait {
|
|
5
|
+
primaryImageUrl: string;
|
|
6
|
+
thumbUrl: string;
|
|
7
|
+
primaryImageUrls?: IModelImageUrls;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PORTRAIT_COUNT = 25;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Тестовые портреты моделей из `public/img/models/`. Формат переключается query-параметром —
|
|
14
|
+
* для замера веса и сравнения качества на одной и той же странице:
|
|
15
|
+
*
|
|
16
|
+
* - `?img=png` — исходники без ладдера, как было до нарезки;
|
|
17
|
+
* - `?img=webp` — ладдер без avif-ветки, чтобы увидеть, сколько даёт именно avif;
|
|
18
|
+
* - без параметра — avif + webp.
|
|
19
|
+
*
|
|
20
|
+
* Читается один раз на монтировании страницы, поэтому переключение — перезагрузкой,
|
|
21
|
+
* а не навигацией по query. Для сравнения сети это и нужно: замер делается с чистого захода.
|
|
22
|
+
*
|
|
23
|
+
* Исходники разнокалиберные: portrait-1..16 — 640×960, portrait-17..25 — 1000×1200. В режиме
|
|
24
|
+
* `png` это видно, нарезка же у всех одинаковая, так что сравнивать с `png` надо с поправкой.
|
|
25
|
+
*/
|
|
26
|
+
export function useTestModelImages() {
|
|
27
|
+
const format = useRoute().query['img'];
|
|
28
|
+
|
|
29
|
+
const portraits: ITestModelPortrait[] = Array.from({ length: PORTRAIT_COUNT }, (_, index) => {
|
|
30
|
+
const base = `/img/models/portrait-${index + 1}`;
|
|
31
|
+
|
|
32
|
+
if (format === 'png') {
|
|
33
|
+
return { primaryImageUrl: `${base}.png`, thumbUrl: `${base}.png`, primaryImageUrls: undefined };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ladder = (extension: string) => ({
|
|
37
|
+
[ModelImageSize.Small]: `${base}-300.${extension}`,
|
|
38
|
+
[ModelImageSize.Medium]: `${base}-600.${extension}`,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
primaryImageUrl: `${base}-600.webp`,
|
|
43
|
+
thumbUrl: `${base}-300.webp`,
|
|
44
|
+
primaryImageUrls: {
|
|
45
|
+
avif: format === 'webp' ? undefined : ladder('avif'),
|
|
46
|
+
webp: ladder('webp'),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const portraitAt = (index: number) => portraits[index % PORTRAIT_COUNT]!;
|
|
52
|
+
|
|
53
|
+
return { portraits, portraitAt };
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -47,10 +47,12 @@ const [
|
|
|
47
47
|
useRouteQueryCategories(),
|
|
48
48
|
]);
|
|
49
49
|
|
|
50
|
-
const
|
|
50
|
+
const { portraits, portraitAt } = useTestModelImages();
|
|
51
51
|
|
|
52
52
|
if (cardInfo.value) {
|
|
53
|
-
|
|
53
|
+
const portrait = portraitAt(Math.floor(Math.random() * portraits.length));
|
|
54
|
+
cardInfo.value.img = portrait.primaryImageUrl;
|
|
55
|
+
cardInfo.value.imgUrls = portrait.primaryImageUrls;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
const { t } = useI18n();
|
package/pages/models/index.vue
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
</template>
|
|
26
26
|
|
|
27
27
|
<script setup lang="ts">
|
|
28
|
-
import type { IBreadcrumbItem } from '../../types';
|
|
28
|
+
import type { IBreadcrumbItem, IModelImageUrls } from '../../types';
|
|
29
29
|
import { ModelsApiService } from '../../services/api/models.service';
|
|
30
30
|
import { AsyncData } from '../../runtime';
|
|
31
31
|
import { sortModels } from '../../lib';
|
|
@@ -34,17 +34,10 @@ const { t } = useI18n();
|
|
|
34
34
|
const pageName = useRoute().name;
|
|
35
35
|
const pagePureName = useGetPureRouteName();
|
|
36
36
|
|
|
37
|
-
const
|
|
37
|
+
const { portraitAt } = useTestModelImages();
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
function applyTestImages<T extends { primaryImageUrl?: string; thumbUrl?: string }>(items: T[] | undefined, images: string[]): T[] | undefined {
|
|
43
|
-
return items?.map((item, i) => ({
|
|
44
|
-
...item,
|
|
45
|
-
primaryImageUrl: images[i % images.length],
|
|
46
|
-
thumbUrl: images[i % images.length],
|
|
47
|
-
}));
|
|
39
|
+
function applyTestImages<T extends { primaryImageUrl?: string; thumbUrl?: string; primaryImageUrls?: IModelImageUrls }>(items: T[] | undefined): T[] | undefined {
|
|
40
|
+
return items?.map((item, i) => ({ ...item, ...portraitAt(i) }));
|
|
48
41
|
}
|
|
49
42
|
|
|
50
43
|
const [
|
|
@@ -56,10 +49,10 @@ const [
|
|
|
56
49
|
]);
|
|
57
50
|
|
|
58
51
|
if (topModels.value?.items) {
|
|
59
|
-
topModels.value.items = applyTestImages(topModels.value.items
|
|
52
|
+
topModels.value.items = applyTestImages(topModels.value.items)!;
|
|
60
53
|
}
|
|
61
54
|
if (models.value?.items) {
|
|
62
|
-
models.value.items = applyTestImages(models.value.items
|
|
55
|
+
models.value.items = applyTestImages(models.value.items)!;
|
|
63
56
|
}
|
|
64
57
|
|
|
65
58
|
const { meta, h1 } = useMeta({
|
|
@@ -125,6 +125,10 @@ const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
|
125
125
|
|
|
126
126
|
const slug = useSlug();
|
|
127
127
|
|
|
128
|
+
const fetchVideos = featureFlags.VectorSearchEnabled
|
|
129
|
+
? useFetchVectorVideosSearch(VideosApiService.getVectorSearch)
|
|
130
|
+
: useFetchVideosSearchByNiche(VideosApiService.getSearchByNiche);
|
|
131
|
+
|
|
128
132
|
const [
|
|
129
133
|
{ data: modelsData },
|
|
130
134
|
{ data: categoriesData },
|
|
@@ -133,7 +137,7 @@ const [
|
|
|
133
137
|
] = await Promise.all([
|
|
134
138
|
useFetchModelsByPhrases(ModelsApiService.getModelsSearch),
|
|
135
139
|
useFetchCategoriesByPhrase(CategoriesApiService.getCategoriesByPhrase),
|
|
136
|
-
|
|
140
|
+
fetchVideos,
|
|
137
141
|
useFetchRelatedSearch(SearchApiService.getPhrases),
|
|
138
142
|
]);
|
|
139
143
|
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
:status="videosStatus"
|
|
11
11
|
:title="h1"
|
|
12
12
|
:data-models="null"
|
|
13
|
-
:sort-items="sortItemsSearch"
|
|
13
|
+
:sort-items="vectorSearch ? undefined : sortItemsSearch"
|
|
14
|
+
:hide-toolbar="vectorSearch"
|
|
15
|
+
:hide-pagination="vectorSearch"
|
|
14
16
|
hide-categories
|
|
15
17
|
/>
|
|
16
18
|
<div
|
|
@@ -30,10 +32,14 @@ const slug = useSlug();
|
|
|
30
32
|
|
|
31
33
|
const { t } = useI18n();
|
|
32
34
|
|
|
35
|
+
const vectorSearch = useRuntimeConfig().public.featureFlags.VectorSearchEnabled;
|
|
36
|
+
|
|
33
37
|
const [
|
|
34
38
|
{ data: videosData, status: videosStatus },
|
|
35
39
|
] = await Promise.all([
|
|
36
|
-
|
|
40
|
+
vectorSearch
|
|
41
|
+
? useFetchVectorVideosSearch(VideosApiService.getVectorSearch, { limit: { mobile: 24, desktop: 48 } })
|
|
42
|
+
: useFetchVideos(VideosApiService.getSearchByNicheFiltered(slug.value), { sortItems: sortItemsSearch, defaultOrder: '-_score' }),
|
|
37
43
|
]);
|
|
38
44
|
|
|
39
45
|
useNoindexThinListing(() => videosData.value?.items?.length);
|
|
@@ -45,7 +51,7 @@ const { meta, h1 } = useMeta({
|
|
|
45
51
|
t,
|
|
46
52
|
pageKey: pagePureName,
|
|
47
53
|
brandName: useAppConfig().brandName,
|
|
48
|
-
sortOptions: sortItemsSearch,
|
|
54
|
+
sortOptions: vectorSearch ? [] : sortItemsSearch,
|
|
49
55
|
text: metaTitle,
|
|
50
56
|
});
|
|
51
57
|
|
|
@@ -13,6 +13,7 @@ export const AsyncData = {
|
|
|
13
13
|
ModelsByFirstLetterChips: 'models-by-first-letter-chips',
|
|
14
14
|
RelatedVideos: 'related-videos',
|
|
15
15
|
VectorRelatedVideos: 'vector-related-videos',
|
|
16
|
+
VectorVideosSearch: 'vector-videos-search',
|
|
16
17
|
RefererPhrases: 'referer-phrases-video',
|
|
17
18
|
AsideVideos: 'aside-videos',
|
|
18
19
|
TopModels: 'top-models',
|
package/runtime/index.ts
CHANGED
|
@@ -8,7 +8,9 @@ export * from './constants/niche';
|
|
|
8
8
|
export * from './constants/report-forms-subjects';
|
|
9
9
|
export * from './constants/playlist-step';
|
|
10
10
|
export * from './constants/thumb-size';
|
|
11
|
+
export * from './constants/model-image-size';
|
|
11
12
|
export * from './constants/webcams';
|
|
13
|
+
export * from './utils/build-srcset';
|
|
12
14
|
export * from './utils/format-date';
|
|
13
15
|
export * from './utils/format-time';
|
|
14
16
|
export * from './utils/get-multiple-query';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildSrcset } from '../build-srcset';
|
|
3
|
+
|
|
4
|
+
describe('buildSrcset', () => {
|
|
5
|
+
it('собирает дескрипторы ширины из ключей карты', () => {
|
|
6
|
+
expect(buildSrcset({
|
|
7
|
+
'300x400': 'https://img.com/a-300.webp',
|
|
8
|
+
'600x800': 'https://img.com/a-600.webp',
|
|
9
|
+
})).toBe('https://img.com/a-300.webp 300w, https://img.com/a-600.webp 600w');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('отбрасывает пустые URL', () => {
|
|
13
|
+
expect(buildSrcset({
|
|
14
|
+
'300x400': '',
|
|
15
|
+
'600x800': 'https://img.com/a-600.webp',
|
|
16
|
+
})).toBe('https://img.com/a-600.webp 600w');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('без карты или без пригодных записей → undefined', () => {
|
|
20
|
+
expect(buildSrcset()).toBeUndefined();
|
|
21
|
+
expect(buildSrcset({})).toBeUndefined();
|
|
22
|
+
expect(buildSrcset({ '300x400': '' })).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Собирает `srcset` из карты `<ширина>x<высота>` → URL. Пустые URL отбрасываются:
|
|
3
|
+
* кандидат с пустым `src` браузер берёт за адрес документа и тянет HTML вместо картинки.
|
|
4
|
+
*/
|
|
5
|
+
export const buildSrcset = (urls?: Partial<Record<string, string>>): string | undefined => {
|
|
6
|
+
if (!urls) return undefined;
|
|
7
|
+
|
|
8
|
+
const entries = Object.entries(urls).filter(([, url]) => Boolean(url));
|
|
9
|
+
if (!entries.length) return undefined;
|
|
10
|
+
|
|
11
|
+
return entries
|
|
12
|
+
.map(([size, url]) => `${url} ${size.split('x')[0]}w`)
|
|
13
|
+
.join(', ');
|
|
14
|
+
};
|
|
@@ -26,6 +26,33 @@ describe('cleanModelCard', () => {
|
|
|
26
26
|
expect(result.country).toEqual({ title: 'United States of America', name: 'us' });
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
it('primary_image_urls → ладдер портрета, недостающий размер пустой строкой', () => {
|
|
30
|
+
const raw = makeRaw({
|
|
31
|
+
primary_image_urls: { webp: { '600x800': 'https://img.com/p-600.webp' } },
|
|
32
|
+
});
|
|
33
|
+
expect(cleanModelCard(raw as any).primaryImageUrls).toEqual({
|
|
34
|
+
avif: undefined,
|
|
35
|
+
webp: { '300x400': '', '600x800': 'https://img.com/p-600.webp' },
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('avif-ветка попадает в ладдер', () => {
|
|
40
|
+
const raw = makeRaw({
|
|
41
|
+
primary_image_urls: {
|
|
42
|
+
avif: { '300x400': 'https://img.com/p-300.avif', '600x800': 'https://img.com/p-600.avif' },
|
|
43
|
+
webp: { '300x400': 'https://img.com/p-300.webp', '600x800': 'https://img.com/p-600.webp' },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
expect(cleanModelCard(raw as any).primaryImageUrls?.avif).toEqual({
|
|
47
|
+
'300x400': 'https://img.com/p-300.avif',
|
|
48
|
+
'600x800': 'https://img.com/p-600.avif',
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('без primary_image_urls → ладдера нет', () => {
|
|
53
|
+
expect(cleanModelCard(makeRaw() as any).primaryImageUrls).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
29
56
|
it('параметры: guy_type, ethnicity, age', () => {
|
|
30
57
|
const result = cleanModelCard(makeRaw() as any);
|
|
31
58
|
expect(result.parameters).toEqual([
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { IRawModelCard, IModelCard, IModelParameter } from '../../../types';
|
|
2
2
|
import { ThumbSize } from '../../constants/thumb-size';
|
|
3
|
+
import { ModelImageSize } from '../../constants/model-image-size';
|
|
3
4
|
|
|
4
5
|
const parameterNames = ['guy_type', 'ethnicity'];
|
|
6
|
+
const pickPortraitSizes = (urls?: Partial<Record<ModelImageSize, string>>) => urls && {
|
|
7
|
+
[ModelImageSize.Small]: urls[ModelImageSize.Small] || '',
|
|
8
|
+
[ModelImageSize.Medium]: urls[ModelImageSize.Medium] || '',
|
|
9
|
+
};
|
|
5
10
|
const firstValue = (card: IRawModelCard, name: string) => card.parameters?.find(item => item.name === name)?.values?.[0];
|
|
6
11
|
const age = (card: IRawModelCard) => firstValue(card, 'age')?.title;
|
|
7
12
|
|
|
@@ -11,6 +16,10 @@ export const cleanModelCard = (card: IRawModelCard): IModelCard => ({
|
|
|
11
16
|
videosCount: card.videos_count || 0,
|
|
12
17
|
thumbUrl: card.video_thumb_urls?.webp?.[ThumbSize.Small] || '',
|
|
13
18
|
primaryImageUrl: card.primary_image_url || '',
|
|
19
|
+
primaryImageUrls: card.primary_image_urls?.webp ? {
|
|
20
|
+
avif: pickPortraitSizes(card.primary_image_urls.avif),
|
|
21
|
+
webp: pickPortraitSizes(card.primary_image_urls.webp)!,
|
|
22
|
+
} : undefined,
|
|
14
23
|
guid: card.guid || '',
|
|
15
24
|
// Страна лежит в parameters под именем nationality_iso_codes: `name` — код ISO-2 (`us`),
|
|
16
25
|
// он же имя файла флага, `title` — читаемое название. Одноимённое поле верхнего уровня
|
|
@@ -44,6 +44,32 @@ describe('convertModelToRoundCard', () => {
|
|
|
44
44
|
expect(result.primaryImageUrl).toBe('');
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
it('пробрасывает ладдер портрета', () => {
|
|
48
|
+
const model = {
|
|
49
|
+
title: 'Test',
|
|
50
|
+
primaryImageUrl: 'https://img.com/primary.jpg',
|
|
51
|
+
primaryImageUrls: { webp: { '300x400': 'https://img.com/a-300.webp' } },
|
|
52
|
+
thumbUrl: '',
|
|
53
|
+
videosCount: 0,
|
|
54
|
+
guid: 'm-5',
|
|
55
|
+
parameters: [],
|
|
56
|
+
};
|
|
57
|
+
const result = convertModelToRoundCard(model);
|
|
58
|
+
expect(result.primaryImageUrls).toEqual({ webp: { '300x400': 'https://img.com/a-300.webp' } });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('без ладдера → undefined', () => {
|
|
62
|
+
const model = {
|
|
63
|
+
title: 'Test',
|
|
64
|
+
primaryImageUrl: 'url',
|
|
65
|
+
thumbUrl: '',
|
|
66
|
+
videosCount: 0,
|
|
67
|
+
guid: 'm-6',
|
|
68
|
+
parameters: [],
|
|
69
|
+
};
|
|
70
|
+
expect(convertModelToRoundCard(model).primaryImageUrls).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
47
73
|
it('slug с пробелами и заглавными', () => {
|
|
48
74
|
const model = {
|
|
49
75
|
title: 'Big Name Model',
|
|
@@ -6,6 +6,7 @@ export function convertModelToRoundCard(model: IModelCard): IRoundCard {
|
|
|
6
6
|
return {
|
|
7
7
|
title: model.title,
|
|
8
8
|
primaryImageUrl: model.primaryImageUrl || model.thumbUrl || '',
|
|
9
|
+
primaryImageUrls: model.primaryImageUrls,
|
|
9
10
|
videosCount: model.videosCount,
|
|
10
11
|
link: `/models/${convertString().toSlug(model.title)}`,
|
|
11
12
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { IVideoCard, IRawVideoCard } from '../../../types';
|
|
2
|
+
import { cleanVideoCard, getUrlWithProxiedParams } from '../../../runtime';
|
|
3
|
+
|
|
4
|
+
/** Поиск роликов по векторной близости: POST /v1/videos-vectors/search, из параметров только limit. */
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
7
|
+
|
|
8
|
+
const url = getUrlWithProxiedParams(`${apiUrl}/v1/videos-vectors/search`, event);
|
|
9
|
+
|
|
10
|
+
const clientHeaders = event.node.req.headers as HeadersInit;
|
|
11
|
+
|
|
12
|
+
const body = await readBody(event);
|
|
13
|
+
|
|
14
|
+
const rawData = await apiFetch(url, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: clientHeaders,
|
|
17
|
+
body: JSON.stringify(body),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const items: IRawVideoCard[] = rawData.data?.items || [];
|
|
21
|
+
|
|
22
|
+
const cleanedItems: IVideoCard[] = items.map(cleanVideoCard);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
currentPage: rawData.data?.current_page,
|
|
26
|
+
allPages: rawData.data?.all_pages,
|
|
27
|
+
total: rawData.data?.total,
|
|
28
|
+
items: cleanedItems,
|
|
29
|
+
};
|
|
30
|
+
});
|
|
@@ -85,6 +85,34 @@ export class VideosApiService {
|
|
|
85
85
|
);
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
+
/** Векторный поиск роликов: без страниц, сортировки и фильтров — только limit. */
|
|
89
|
+
static async getVectorSearch(
|
|
90
|
+
limit: number,
|
|
91
|
+
lang: Language,
|
|
92
|
+
phrase: string,
|
|
93
|
+
niche = NicheHelper.getSiteDefaultNiche(),
|
|
94
|
+
): Promise<PaginatedResponse<IVideoCard>> {
|
|
95
|
+
return await ApiHelper.fetch(
|
|
96
|
+
'/videos/vector-search',
|
|
97
|
+
{
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
'X-Niche': niche,
|
|
101
|
+
'X-Language': toHeaderLanguage(lang),
|
|
102
|
+
'X-Cache-Time': '259200',
|
|
103
|
+
'Content-Type': 'application/json',
|
|
104
|
+
},
|
|
105
|
+
params: {
|
|
106
|
+
limit,
|
|
107
|
+
},
|
|
108
|
+
body: {
|
|
109
|
+
query: phrase,
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
useRuntimeConfig(),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
static async sendVideoAbuse(
|
|
89
117
|
data: IReportForm,
|
|
90
118
|
videoGuid: string,
|
package/stubs/library-follows.ts
CHANGED
|
@@ -68,21 +68,21 @@ function buildVideos(slug: string, offset: number, newCount: number): LibraryFol
|
|
|
68
68
|
|
|
69
69
|
/** Порядок по числу новинок намеренно не совпадает с алфавитным — иначе обе сортировки дают одно и то же. */
|
|
70
70
|
const MODELS = [
|
|
71
|
-
{ title: 'Dakota Lovell', image: 'portrait-1.
|
|
72
|
-
{ title: 'Jax Thirio', image: 'portrait-2.
|
|
73
|
-
{ title: 'Austin Young', image: 'portrait-3.
|
|
74
|
-
{ title: 'Devin Franco', image: 'portrait-4.
|
|
75
|
-
{ title: 'Dakota Lovell', image: 'portrait-1.
|
|
76
|
-
{ title: 'Jax Thirio', image: 'portrait-2.
|
|
77
|
-
{ title: 'Austin Young', image: 'portrait-3.
|
|
78
|
-
{ title: 'Devin Franco', image: 'portrait-4.
|
|
79
|
-
{ title: 'Dakota Lovell', image: 'portrait-1.
|
|
80
|
-
{ title: 'Dakota Lovell', image: 'portrait-1.
|
|
81
|
-
{ title: 'Dakota Lovell', image: 'portrait-1.
|
|
82
|
-
{ title: 'Dakota Lovell', image: 'portrait-1.
|
|
83
|
-
{ title: 'Jax Thirio', image: 'portrait-2.
|
|
84
|
-
{ title: 'Austin Young', image: 'portrait-3.
|
|
85
|
-
{ title: 'Devin Franco', image: 'portrait-4.
|
|
71
|
+
{ title: 'Dakota Lovell', image: 'portrait-1-300.webp', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
|
|
72
|
+
{ title: 'Jax Thirio', image: 'portrait-2-300.webp', country: { name: 'ca', title: 'Canada' }, age: 38, videosCount: 17, newCount: 2, guyType: 'Daddy', bodyBuild: 'Muscular', ethnicity: 'Latino', updatedAgo: 2 * DAY, videoOffset: 9 },
|
|
73
|
+
{ title: 'Austin Young', image: 'portrait-3-300.webp', country: { name: 'us', title: 'USA' }, age: 24, videosCount: 75, newCount: 1, guyType: 'Twink', bodyBuild: 'Athletic', ethnicity: 'Caucasian', updatedAgo: 9 * DAY, videoOffset: 0 },
|
|
74
|
+
{ title: 'Devin Franco', image: 'portrait-4-300.webp', country: { name: 'us', title: 'USA' }, age: 33, videosCount: 24, newCount: 0, guyType: 'Jock', bodyBuild: 'Muscular', ethnicity: 'Black', updatedAgo: 21 * DAY, videoOffset: 6 },
|
|
75
|
+
{ title: 'Dakota Lovell', image: 'portrait-1-300.webp', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
|
|
76
|
+
{ title: 'Jax Thirio', image: 'portrait-2-300.webp', country: { name: 'ca', title: 'Canada' }, age: 38, videosCount: 17, newCount: 2, guyType: 'Daddy', bodyBuild: 'Muscular', ethnicity: 'Latino', updatedAgo: 2 * DAY, videoOffset: 9 },
|
|
77
|
+
{ title: 'Austin Young', image: 'portrait-3-300.webp', country: { name: 'us', title: 'USA' }, age: 24, videosCount: 75, newCount: 1, guyType: 'Twink', bodyBuild: 'Athletic', ethnicity: 'Caucasian', updatedAgo: 9 * DAY, videoOffset: 0 },
|
|
78
|
+
{ title: 'Devin Franco', image: 'portrait-4-300.webp', country: { name: 'us', title: 'USA' }, age: 33, videosCount: 24, newCount: 0, guyType: 'Jock', bodyBuild: 'Muscular', ethnicity: 'Black', updatedAgo: 21 * DAY, videoOffset: 6 },
|
|
79
|
+
{ title: 'Dakota Lovell', image: 'portrait-1-300.webp', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
|
|
80
|
+
{ title: 'Dakota Lovell', image: 'portrait-1-300.webp', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
|
|
81
|
+
{ title: 'Dakota Lovell', image: 'portrait-1-300.webp', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
|
|
82
|
+
{ title: 'Dakota Lovell', image: 'portrait-1-300.webp', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
|
|
83
|
+
{ title: 'Jax Thirio', image: 'portrait-2-300.webp', country: { name: 'ca', title: 'Canada' }, age: 38, videosCount: 17, newCount: 2, guyType: 'Daddy', bodyBuild: 'Muscular', ethnicity: 'Latino', updatedAgo: 2 * DAY, videoOffset: 9 },
|
|
84
|
+
{ title: 'Austin Young', image: 'portrait-3-300.webp', country: { name: 'us', title: 'USA' }, age: 24, videosCount: 75, newCount: 1, guyType: 'Twink', bodyBuild: 'Athletic', ethnicity: 'Caucasian', updatedAgo: 9 * DAY, videoOffset: 0 },
|
|
85
|
+
{ title: 'Devin Franco', image: 'portrait-4-300.webp', country: { name: 'us', title: 'USA' }, age: 33, videosCount: 24, newCount: 0, guyType: 'Jock', bodyBuild: 'Muscular', ethnicity: 'Black', updatedAgo: 21 * DAY, videoOffset: 6 },
|
|
86
86
|
];
|
|
87
87
|
|
|
88
88
|
const CHANNELS = [
|
package/types/card-info.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IParameterModel } from './parameter-model';
|
|
2
2
|
import type { IChipsItem } from './chips-item';
|
|
3
|
+
import type { IModelImageUrls } from './model-image-urls';
|
|
3
4
|
import type { IPageSeo } from './page-seo';
|
|
4
5
|
|
|
5
6
|
export interface ICardInfo {
|
|
@@ -7,6 +8,8 @@ export interface ICardInfo {
|
|
|
7
8
|
description: string;
|
|
8
9
|
videosCount: number;
|
|
9
10
|
img?: string;
|
|
11
|
+
/** Ладдер портрета под srcset. Нет — рендерится один `img`. */
|
|
12
|
+
imgUrls?: IModelImageUrls;
|
|
10
13
|
guid?: string;
|
|
11
14
|
fullDescription?: string
|
|
12
15
|
miniCardTitle?: string
|
package/types/index.d.ts
CHANGED
|
@@ -2,7 +2,9 @@ export * from './site.d.ts';
|
|
|
2
2
|
export * from './video-data.d.ts';
|
|
3
3
|
export * from './video-card.d.ts';
|
|
4
4
|
export * from './thumbs-urls.d.ts';
|
|
5
|
+
export * from './model-image-urls.d.ts';
|
|
5
6
|
export * from './raw/raw-thumbs-urls.d.ts';
|
|
7
|
+
export * from './raw/raw-model-image-urls.d.ts';
|
|
6
8
|
export * from './raw/raw-category-info.d.ts';
|
|
7
9
|
export * from './raw/raw-category-card.d.ts';
|
|
8
10
|
export * from './raw/raw-channel-card.d.ts';
|
package/types/model-card.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import type { IModelImageUrls } from './model-image-urls';
|
|
2
|
+
|
|
1
3
|
export interface IModelCard {
|
|
2
4
|
title: string;
|
|
3
5
|
videosCount: number;
|
|
4
6
|
thumbUrl: string;
|
|
5
7
|
primaryImageUrl?: string;
|
|
8
|
+
/** Ладдер портрета под srcset. Нет — карточка рендерит один `primaryImageUrl`. */
|
|
9
|
+
primaryImageUrls?: IModelImageUrls;
|
|
6
10
|
guid: string;
|
|
7
11
|
age?: string;
|
|
8
12
|
country?: {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ModelImageSize } from '../runtime/constants/model-image-size';
|
|
2
|
+
|
|
3
|
+
/** Очищенный портрет модели, ключ `<ширина>x<высота>`. `avif` необязателен — webp несущий. */
|
|
4
|
+
export interface IModelImageUrls {
|
|
5
|
+
avif?: Partial<Record<ModelImageSize, string>>;
|
|
6
|
+
webp: Partial<Record<ModelImageSize, string>>;
|
|
7
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IRawThumbUrls } from './raw-thumbs-urls';
|
|
2
|
+
import type { IRawModelImageUrls } from './raw-model-image-urls';
|
|
2
3
|
import type { IModelParameter } from '~/types';
|
|
3
4
|
|
|
4
5
|
export interface IRawModelCard {
|
|
@@ -8,6 +9,7 @@ export interface IRawModelCard {
|
|
|
8
9
|
guid: string;
|
|
9
10
|
likes: number;
|
|
10
11
|
primary_image_url: string;
|
|
12
|
+
primary_image_urls?: IRawModelImageUrls;
|
|
11
13
|
number_of_videos_in_recent_days: number;
|
|
12
14
|
orientation: string;
|
|
13
15
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ModelImageSize } from '../../runtime/constants/model-image-size';
|
|
2
|
+
|
|
3
|
+
/** Raw портрет модели из API — зеркало `video_thumb_urls`, но с портретными размерами */
|
|
4
|
+
export interface IRawModelImageUrls {
|
|
5
|
+
jpeg?: Partial<Record<ModelImageSize, string>>;
|
|
6
|
+
webp?: Partial<Record<ModelImageSize, string>>;
|
|
7
|
+
avif?: Partial<Record<ModelImageSize, string>>;
|
|
8
|
+
}
|
package/types/round-card.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import type { IModelImageUrls } from './model-image-urls';
|
|
2
|
+
|
|
1
3
|
export interface IRoundCard {
|
|
2
4
|
title: string
|
|
3
5
|
primaryImageUrl: string
|
|
6
|
+
/** Ладдер портрета под srcset. Нет — карточка рендерит один `primaryImageUrl`. */
|
|
7
|
+
primaryImageUrls?: IModelImageUrls
|
|
4
8
|
videosCount: number
|
|
5
9
|
link: string
|
|
6
10
|
/** Новинки с прошлого захода — бейдж на аватаре (страница подписок) */
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import type { IModelImageUrls } from './model-image-urls';
|
|
2
|
+
|
|
1
3
|
export interface ISearchTopModelsType {
|
|
2
4
|
videosCount?: number
|
|
3
5
|
name: string
|
|
4
6
|
title: string
|
|
5
7
|
primaryImageUrl?: string
|
|
8
|
+
/** Ладдер портрета под srcset. Нет — рендерится один `primaryImageUrl`. */
|
|
9
|
+
primaryImageUrls?: IModelImageUrls
|
|
6
10
|
}
|