itube-specs 0.0.967 → 0.0.969
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 +4 -2
- package/components/search/item.vue +2 -1
- package/components/ui/dropdown.vue +92 -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,
|
|
@@ -89,8 +89,10 @@ defineProps<{
|
|
|
89
89
|
inset: 0;
|
|
90
90
|
overflow-y: auto;
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
/* Контейнер прокрутки обрезает всё, что элементы рисуют ЗА своей границей, —
|
|
93
|
+
кольцо аватара модели и рамки фокуса. Отступ в 2px даёт им место; справа
|
|
94
|
+
к нему добавлено расстояние до полосы прокрутки. */
|
|
95
|
+
padding: 2px 6px 2px 2px;
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
</style>
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
{'--center': center},
|
|
9
9
|
{'--open': open},
|
|
10
10
|
{'--separate': separateLastChild},
|
|
11
|
+
{'--escaped': escaped},
|
|
11
12
|
]"
|
|
12
13
|
class="ui-dropdown"
|
|
13
14
|
@mouseleave="mouseHandler(false)"
|
|
@@ -26,7 +27,10 @@
|
|
|
26
27
|
<dialog
|
|
27
28
|
v-if="useVIf ? open && $slots.items : true"
|
|
28
29
|
ref="menuRef"
|
|
29
|
-
:style="
|
|
30
|
+
:style="[
|
|
31
|
+
!useVIf ? { display: (open && $slots.items) ? '' : 'none' } : {},
|
|
32
|
+
escapedStyle,
|
|
33
|
+
]"
|
|
30
34
|
:class="{'--open': open}"
|
|
31
35
|
class="ui-dropdown__menu"
|
|
32
36
|
@click="close"
|
|
@@ -92,9 +96,75 @@ const { registerOverlay, releaseOverlay } = useOverlayStack();
|
|
|
92
96
|
const breakpoints = CSS_BREAKPOINTS;
|
|
93
97
|
const { isTablet } = checkDeviceWidth(breakpoints);
|
|
94
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Выпадашка внутри прокручиваемого блока (боковая колонка плеера, например)
|
|
101
|
+
* обрезается его `overflow` — меню уходит под край. Уводить его в `position: fixed`
|
|
102
|
+
* всегда не стали: у обычных выпадашек координаты считает браузер, и это надёжнее.
|
|
103
|
+
* Поэтому смотрим, есть ли над нами прокручиваемый предок, и только тогда считаем
|
|
104
|
+
* координаты сами. `overflow: hidden` не в счёт — там выпадашки и так живут.
|
|
105
|
+
*/
|
|
106
|
+
function hasScrollableAncestor(element: HTMLElement | null): boolean {
|
|
107
|
+
let node = element?.parentElement || null;
|
|
108
|
+
|
|
109
|
+
while (node && node !== document.body) {
|
|
110
|
+
const { overflowX, overflowY } = getComputedStyle(node);
|
|
111
|
+
|
|
112
|
+
if (/auto|scroll/.test(`${overflowX} ${overflowY}`)) return true;
|
|
113
|
+
|
|
114
|
+
node = node.parentElement;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Меню выведено из потока и позиционируется от экрана. */
|
|
121
|
+
const escaped = ref(false);
|
|
122
|
+
const escapedStyle = ref<Record<string, string>>({});
|
|
123
|
+
|
|
124
|
+
/** Координаты меню от триггера: вниз, а если не влезает — вверх; прижимаем к экрану. */
|
|
125
|
+
function placeEscaped() {
|
|
126
|
+
const trigger = dropdownRef.value?.getBoundingClientRect();
|
|
127
|
+
const menu = menuRef.value;
|
|
128
|
+
if (!trigger || !menu) return;
|
|
129
|
+
|
|
130
|
+
const gap = 4;
|
|
131
|
+
const edge = 8;
|
|
132
|
+
const { offsetWidth: width, offsetHeight: height } = menu;
|
|
133
|
+
|
|
134
|
+
const below = trigger.bottom + gap;
|
|
135
|
+
const flip = below + height > window.innerHeight;
|
|
136
|
+
autoTop.value = flip;
|
|
137
|
+
|
|
138
|
+
let left = props.right ? trigger.right - width : trigger.left;
|
|
139
|
+
if (props.center) left = trigger.left + (trigger.width - width) / 2;
|
|
140
|
+
|
|
141
|
+
escapedStyle.value = {
|
|
142
|
+
top: `${Math.max(edge, flip ? trigger.top - height - gap : below)}px`,
|
|
143
|
+
left: `${Math.min(Math.max(edge, left), window.innerWidth - width - edge)}px`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Прокрутка любого предка сдвигает триггер — пересчитываем, пока меню открыто. */
|
|
148
|
+
function onViewportChange() {
|
|
149
|
+
requestAnimationFrame(placeEscaped);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function bindViewportListeners(bind: boolean) {
|
|
153
|
+
const method = bind ? 'addEventListener' : 'removeEventListener';
|
|
154
|
+
|
|
155
|
+
window[method]('scroll', onViewportChange, true);
|
|
156
|
+
window[method]('resize', onViewportChange);
|
|
157
|
+
}
|
|
158
|
+
|
|
95
159
|
function checkPosition() {
|
|
96
160
|
if (!dropdownRef.value || !menuRef.value || isTablet.value) return;
|
|
97
161
|
requestAnimationFrame(() => {
|
|
162
|
+
if (escaped.value) {
|
|
163
|
+
placeEscaped();
|
|
164
|
+
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
98
168
|
const triggerRect = dropdownRef.value.getBoundingClientRect();
|
|
99
169
|
const menuHeight = menuRef.value.scrollHeight;
|
|
100
170
|
const spaceBelow = window.innerHeight - triggerRect.bottom;
|
|
@@ -104,6 +174,12 @@ function checkPosition() {
|
|
|
104
174
|
|
|
105
175
|
function openDropdown() {
|
|
106
176
|
open.value = !open.value;
|
|
177
|
+
|
|
178
|
+
if (open.value && !isTablet.value) {
|
|
179
|
+
escaped.value = hasScrollableAncestor(dropdownRef.value);
|
|
180
|
+
if (escaped.value) bindViewportListeners(true);
|
|
181
|
+
}
|
|
182
|
+
|
|
107
183
|
if (open.value) nextTick(() => checkPosition());
|
|
108
184
|
|
|
109
185
|
if (isTablet.value && open.value) {
|
|
@@ -117,6 +193,8 @@ function openDropdown() {
|
|
|
117
193
|
|
|
118
194
|
function close() {
|
|
119
195
|
open.value = false;
|
|
196
|
+
bindViewportListeners(false);
|
|
197
|
+
escapedStyle.value = {};
|
|
120
198
|
|
|
121
199
|
if (isTablet.value) {
|
|
122
200
|
nextTick(() => {
|
|
@@ -139,6 +217,8 @@ watch(open, (val) => {
|
|
|
139
217
|
});
|
|
140
218
|
|
|
141
219
|
onBeforeUnmount(() => {
|
|
220
|
+
bindViewportListeners(false);
|
|
221
|
+
|
|
142
222
|
if (open.value && isTablet.value) {
|
|
143
223
|
scrollUnlock();
|
|
144
224
|
releaseOverlay(menuRef.value);
|
|
@@ -278,6 +358,17 @@ defineExpose({ openDropdown, open });
|
|
|
278
358
|
}
|
|
279
359
|
}
|
|
280
360
|
|
|
361
|
+
/* Меню внутри прокручиваемого блока: позиционируется от экрана, координаты считает
|
|
362
|
+
скрипт (см. placeEscaped) — браузерные `bottom`/`transform` здесь только мешают. */
|
|
363
|
+
.ui-dropdown.--escaped .ui-dropdown__menu {
|
|
364
|
+
@include from-br(md) {
|
|
365
|
+
position: fixed;
|
|
366
|
+
right: auto;
|
|
367
|
+
bottom: auto;
|
|
368
|
+
transform: none;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
281
372
|
.ui-dropdown__menu-items > .ui-dropdown__item {
|
|
282
373
|
@include font-md;
|
|
283
374
|
|
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
|
}
|