itube-specs 0.0.981 → 0.0.982
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 +3 -8
- package/components/card/model.vue +1 -3
- package/components/card/round.vue +0 -2
- package/components/info/main.vue +0 -4
- package/components/search/item.vue +13 -4
- package/components/ui/img.vue +3 -5
- package/components/view/video.vue +2 -5
- package/nuxt.config.ts +3 -0
- package/package.json +1 -1
- package/pages/models/[slug]/index.vue +0 -8
- package/pages/models/index.vue +1 -14
- package/runtime/index.ts +1 -0
- package/runtime/utils/__tests__/pick-portrait-variant.test.ts +25 -0
- package/runtime/utils/cleaners/__tests__/clean-model-card.test.ts +25 -7
- package/runtime/utils/cleaners/__tests__/clean-model-info.test.ts +3 -1
- package/runtime/utils/cleaners/clean-model-card.ts +34 -26
- package/runtime/utils/cleaners/clean-model-info.ts +21 -15
- package/runtime/utils/converters/__tests__/convert-model-to-round-card.test.ts +8 -22
- package/runtime/utils/converters/convert-model-to-round-card.ts +3 -4
- package/runtime/utils/pick-portrait-variant.ts +18 -0
- package/server/api/models/get-info-by-name.get.ts +1 -1
- package/server/api/models/get-models-by-phrase.get.ts +2 -1
- package/server/api/models/list-by-category/[category].get.ts +2 -1
- package/server/api/models/list-by-channel/[channel].get.ts +2 -1
- package/server/api/models/list-by-first-letter.get.ts +2 -1
- package/server/api/models/list-by-niche.post.ts +2 -1
- package/server/api/models/list-by-tag/[tag].get.ts +2 -1
- package/server/api/models/related.get.ts +2 -1
- package/server/api/models/top.get.ts +2 -1
- package/stubs/library-follows.ts +17 -17
- package/types/card-info.d.ts +0 -3
- package/types/index.d.ts +1 -2
- package/types/model-card.d.ts +0 -4
- package/types/raw/raw-model-card.d.ts +2 -2
- package/types/raw/raw-model-info.d.ts +2 -0
- package/types/raw/raw-portraits.d.ts +22 -0
- package/types/round-card.d.ts +0 -4
- package/types/search-top-models.d.ts +3 -3
- package/composables/use-test-model-images.ts +0 -69
- package/types/model-image-urls.d.ts +0 -14
- package/types/raw/raw-model-image-urls.d.ts +0 -5
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
<div class="card-model-mini">
|
|
4
4
|
<UiImg
|
|
5
5
|
:src="currentImage"
|
|
6
|
-
:image-urls="currentImageUrls"
|
|
7
6
|
:avif-src="currentAvifSrc"
|
|
8
7
|
:alt="card.title"
|
|
9
8
|
class="card-model-mini__img"
|
|
@@ -56,17 +55,13 @@ const props = defineProps<{
|
|
|
56
55
|
const fallbackImage = '/img/model-placeholder.webp';
|
|
57
56
|
const imageError = ref(false);
|
|
58
57
|
|
|
59
|
-
/** Аватар 48px
|
|
58
|
+
/** Аватар 48px круглый — нужен квадратный кроп, вертикальный портрет сюда не годится. */
|
|
60
59
|
const currentImage = computed(() => {
|
|
61
60
|
if (imageError.value) return fallbackImage;
|
|
62
|
-
return props.card.primaryImageSmallUrl ||
|
|
61
|
+
return props.card.primaryImageSmallUrl || fallbackImage;
|
|
63
62
|
});
|
|
64
63
|
|
|
65
|
-
const
|
|
66
|
-
const currentAvifSrc = computed(() => {
|
|
67
|
-
if (imageError.value) return undefined;
|
|
68
|
-
return props.card.primaryImageSmallAvifUrl || props.card.primaryImageAvifUrl;
|
|
69
|
-
});
|
|
64
|
+
const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.primaryImageSmallAvifUrl));
|
|
70
65
|
</script>
|
|
71
66
|
|
|
72
67
|
<style lang="scss">
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
<div class="card-model__thumb">
|
|
9
9
|
<UiImg
|
|
10
10
|
:src="currentImage"
|
|
11
|
-
:image-urls="currentImageUrls"
|
|
12
11
|
:avif-src="currentAvifSrc"
|
|
13
12
|
:loading="loading"
|
|
14
13
|
:alt="card.title"
|
|
@@ -104,11 +103,10 @@ const imageError = ref(false);
|
|
|
104
103
|
|
|
105
104
|
const currentImage = computed(() => {
|
|
106
105
|
if (imageError.value) return fallbackImage;
|
|
107
|
-
return props.card.primaryImageUrl ||
|
|
106
|
+
return props.card.primaryImageUrl || fallbackImage;
|
|
108
107
|
});
|
|
109
108
|
|
|
110
109
|
/** На заглушке ладдер сбрасываем — иначе браузер продолжит тянуть битые кандидаты. */
|
|
111
|
-
const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
|
|
112
110
|
const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.primaryImageAvifUrl));
|
|
113
111
|
|
|
114
112
|
function onImageError() {
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
>
|
|
7
7
|
<UiImg
|
|
8
8
|
:src="currentImage"
|
|
9
|
-
:image-urls="currentImageUrls"
|
|
10
9
|
:avif-src="currentAvifSrc"
|
|
11
10
|
:loading="loading"
|
|
12
11
|
:alt="card.title"
|
|
@@ -70,7 +69,6 @@ const currentImage = computed(() => {
|
|
|
70
69
|
return props.card.primaryImageUrl || fallbackImage || '';
|
|
71
70
|
});
|
|
72
71
|
|
|
73
|
-
const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
|
|
74
72
|
const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.primaryImageAvifUrl));
|
|
75
73
|
|
|
76
74
|
function onImageError() {
|
package/components/info/main.vue
CHANGED
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
v-if="card.img"
|
|
17
17
|
:src="currentImage"
|
|
18
18
|
:avif-src="currentAvifSrc"
|
|
19
|
-
:image-urls="currentImageUrls"
|
|
20
19
|
:alt="title"
|
|
21
20
|
:sizes="IMG_SIZES"
|
|
22
21
|
class="info-main__img"
|
|
@@ -216,8 +215,6 @@ const currentImage = computed(() => {
|
|
|
216
215
|
/** `.info-main__img` — 80px до sm, 96px дальше. `auto` впереди — см. `UiImg`. */
|
|
217
216
|
const IMG_SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
|
|
218
217
|
|
|
219
|
-
const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.imgUrls));
|
|
220
|
-
|
|
221
218
|
const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.imgAvifUrl));
|
|
222
219
|
|
|
223
220
|
function getParameter(name: string) {
|
|
@@ -241,7 +238,6 @@ const shareModelCard = computed<IModelCard>(() => ({
|
|
|
241
238
|
title: props.card.title,
|
|
242
239
|
videosCount: props.card.videosCount,
|
|
243
240
|
thumbUrl: props.card.img || '',
|
|
244
|
-
primaryImageUrls: props.card.imgUrls,
|
|
245
241
|
guid: props.card.guid || '',
|
|
246
242
|
parameters: [],
|
|
247
243
|
country: shareCountry.value,
|
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
>
|
|
11
11
|
<UiImg
|
|
12
12
|
v-if="isModel"
|
|
13
|
-
:src="
|
|
14
|
-
:avif-src="
|
|
15
|
-
:image-urls="item?.primaryImageUrls"
|
|
13
|
+
:src="icon.webp"
|
|
14
|
+
:avif-src="icon.avif"
|
|
16
15
|
:alt="title"
|
|
17
16
|
class="search-item__icon"
|
|
18
17
|
sizes="auto, 32px"
|
|
@@ -32,7 +31,7 @@
|
|
|
32
31
|
|
|
33
32
|
<script setup lang="ts">
|
|
34
33
|
import type { ISearchTopModelsType } from '../../types';
|
|
35
|
-
import { convertString } from '../../runtime';
|
|
34
|
+
import { convertString, pickPortraitVariant } from '../../runtime';
|
|
36
35
|
import DOMPurify from 'dompurify';
|
|
37
36
|
|
|
38
37
|
const props = defineProps<{
|
|
@@ -42,6 +41,16 @@ const props = defineProps<{
|
|
|
42
41
|
isDefault?: boolean
|
|
43
42
|
}>();
|
|
44
43
|
|
|
44
|
+
/** Иконка 32px — квадратный кроп по лицу, если он есть в ответе. */
|
|
45
|
+
const icon = computed(() => {
|
|
46
|
+
const square = pickPortraitVariant(props.item?.portraits)?.square;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
webp: square?.webp || '/img/model-placeholder.webp',
|
|
50
|
+
avif: square?.avif,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
45
54
|
const title = computed(() => props.item?.title?.replace(/<[^>]*>/g, ''));
|
|
46
55
|
const { generateLink } = useGenerateLink();
|
|
47
56
|
|
package/components/ui/img.vue
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
</template>
|
|
25
25
|
|
|
26
26
|
<script setup lang="ts">
|
|
27
|
-
import type { IThumbUrls
|
|
27
|
+
import type { IThumbUrls } from '../../types';
|
|
28
28
|
import { ThumbSize, buildSrcset } from '../../runtime';
|
|
29
29
|
|
|
30
30
|
defineOptions({ inheritAttrs: false });
|
|
@@ -37,8 +37,6 @@ const props = withDefaults(defineProps<{
|
|
|
37
37
|
height: string | number,
|
|
38
38
|
sizes?: string,
|
|
39
39
|
urls?: IThumbUrls,
|
|
40
|
-
/** Ладдер по ширине (портреты моделей). Старше `urls`. */
|
|
41
|
-
imageUrls?: IModelImageUrls,
|
|
42
40
|
alt?: string,
|
|
43
41
|
loading?: 'lazy' | 'eager',
|
|
44
42
|
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside',
|
|
@@ -72,10 +70,10 @@ const imgSrc = computed(() => {
|
|
|
72
70
|
return undefined;
|
|
73
71
|
});
|
|
74
72
|
|
|
75
|
-
const computedSrcset = computed(() => buildSrcset(props.
|
|
73
|
+
const computedSrcset = computed(() => buildSrcset(props.urls?.webp));
|
|
76
74
|
|
|
77
75
|
/** Ладдер старше одиночного `avifSrc`: нужен, когда ступени есть и в avif-ветке. */
|
|
78
|
-
const avifSrcset = computed(() =>
|
|
76
|
+
const avifSrcset = computed(() => props.avifSrc);
|
|
79
77
|
|
|
80
78
|
const imgAttrs = computed(() => ({
|
|
81
79
|
...attrs,
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
<UiChips
|
|
23
23
|
v-for="(model, index) in data.models"
|
|
24
24
|
:key="index"
|
|
25
|
-
:item="getModelChips(model
|
|
25
|
+
:item="getModelChips(model)"
|
|
26
26
|
is-link
|
|
27
27
|
/>
|
|
28
28
|
</template>
|
|
@@ -203,14 +203,11 @@ const topChips = computed(() => {
|
|
|
203
203
|
|
|
204
204
|
const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
|
|
205
205
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
function getModelChips(model: IVideoData['models'][number], index: number): IChipsItem {
|
|
206
|
+
function getModelChips(model: IVideoData['models'][number]): IChipsItem {
|
|
209
207
|
return {
|
|
210
208
|
prefix: '/models',
|
|
211
209
|
value: model.title,
|
|
212
210
|
title: model.title,
|
|
213
|
-
image: portraitAt(index).thumbUrl,
|
|
214
211
|
};
|
|
215
212
|
}
|
|
216
213
|
</script>
|
package/nuxt.config.ts
CHANGED
|
@@ -64,6 +64,9 @@ export default defineNuxtConfig({
|
|
|
64
64
|
public: {
|
|
65
65
|
featureFlags: {},
|
|
66
66
|
adsConfig: {},
|
|
67
|
+
// Тема сайта. Дублирует `appConfig.theme`, потому что серверные хендлеры читают только
|
|
68
|
+
// runtimeConfig: по ней выбирается вариант портрета из `portraits`.
|
|
69
|
+
theme: '',
|
|
67
70
|
webcamsConfig: {},
|
|
68
71
|
locales: {
|
|
69
72
|
codes: [],
|
package/package.json
CHANGED
|
@@ -47,14 +47,6 @@ const [
|
|
|
47
47
|
useRouteQueryCategories(),
|
|
48
48
|
]);
|
|
49
49
|
|
|
50
|
-
const { portraits, portraitAt } = useTestModelImages();
|
|
51
|
-
|
|
52
|
-
if (cardInfo.value) {
|
|
53
|
-
const portrait = portraitAt(Math.floor(Math.random() * portraits.length));
|
|
54
|
-
cardInfo.value.img = portrait.primaryImageUrl;
|
|
55
|
-
cardInfo.value.imgUrls = portrait.primaryImageUrls;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
50
|
const { t } = useI18n();
|
|
59
51
|
|
|
60
52
|
const title = convertString().fromSlug(String(route.params[ 'slug' ]));
|
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
|
|
28
|
+
import type { IBreadcrumbItem } from '../../types';
|
|
29
29
|
import { ModelsApiService } from '../../services/api/models.service';
|
|
30
30
|
import { AsyncData } from '../../runtime';
|
|
31
31
|
import { sortModels } from '../../lib';
|
|
@@ -34,12 +34,6 @@ const { t } = useI18n();
|
|
|
34
34
|
const pageName = useRoute().name;
|
|
35
35
|
const pagePureName = useGetPureRouteName();
|
|
36
36
|
|
|
37
|
-
const { portraitAt } = useTestModelImages();
|
|
38
|
-
|
|
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) }));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
37
|
const [
|
|
44
38
|
{ data: topModels },
|
|
45
39
|
{ data: models, status },
|
|
@@ -48,13 +42,6 @@ const [
|
|
|
48
42
|
useFetchModels(ModelsApiService.getListByNiche),
|
|
49
43
|
]);
|
|
50
44
|
|
|
51
|
-
if (topModels.value?.items) {
|
|
52
|
-
topModels.value.items = applyTestImages(topModels.value.items)!;
|
|
53
|
-
}
|
|
54
|
-
if (models.value?.items) {
|
|
55
|
-
models.value.items = applyTestImages(models.value.items)!;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
45
|
const { meta, h1 } = useMeta({
|
|
59
46
|
t,
|
|
60
47
|
pageKey: pagePureName,
|
package/runtime/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './constants/playlist-step';
|
|
|
10
10
|
export * from './constants/thumb-size';
|
|
11
11
|
export * from './constants/webcams';
|
|
12
12
|
export * from './utils/build-srcset';
|
|
13
|
+
export * from './utils/pick-portrait-variant';
|
|
13
14
|
export * from './utils/format-date';
|
|
14
15
|
export * from './utils/format-time';
|
|
15
16
|
export * from './utils/get-multiple-query';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { pickPortraitVariant } from '../pick-portrait-variant';
|
|
3
|
+
|
|
4
|
+
const tall = { width: 640, height: 960, webp: 'w', avif: 'a' };
|
|
5
|
+
|
|
6
|
+
describe('pickPortraitVariant', () => {
|
|
7
|
+
it('берёт запрошенный вариант', () => {
|
|
8
|
+
const portraits = { dark: { tall }, light: { tall: { ...tall, webp: 'light' } } };
|
|
9
|
+
expect(pickPortraitVariant(portraits, 'light')?.tall?.webp).toBe('light');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('запрошенного нет → dark', () => {
|
|
13
|
+
const portraits = { dark: { tall }, light: { tall: { ...tall, webp: 'light' } } };
|
|
14
|
+
expect(pickPortraitVariant(portraits, 'sepia')?.tall?.webp).toBe('w');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('dark нет → первый попавшийся', () => {
|
|
18
|
+
expect(pickPortraitVariant({ light: { tall: { ...tall, webp: 'light' } } })?.tall?.webp).toBe('light');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('пусто → undefined', () => {
|
|
22
|
+
expect(pickPortraitVariant()).toBeUndefined();
|
|
23
|
+
expect(pickPortraitVariant({})).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -26,17 +26,35 @@ describe('cleanModelCard', () => {
|
|
|
26
26
|
expect(result.country).toEqual({ title: 'United States of America', name: 'us' });
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
it('
|
|
29
|
+
it('portraits → tall в карточку, square в аватары', () => {
|
|
30
30
|
const raw = makeRaw({
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
portraits: {
|
|
32
|
+
dark: {
|
|
33
|
+
tall: { width: 640, height: 960, webp: 'https://cdn/t.webp', avif: 'https://cdn/t.avif' },
|
|
34
|
+
square: { width: 256, height: 256, webp: 'https://cdn/s.webp', avif: 'https://cdn/s.avif' },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
35
37
|
});
|
|
38
|
+
const result = cleanModelCard(raw as any);
|
|
39
|
+
expect(result.primaryImageUrl).toBe('https://cdn/t.webp');
|
|
40
|
+
expect(result.primaryImageAvifUrl).toBe('https://cdn/t.avif');
|
|
41
|
+
expect(result.primaryImageSmallUrl).toBe('https://cdn/s.webp');
|
|
42
|
+
expect(result.primaryImageSmallAvifUrl).toBe('https://cdn/s.avif');
|
|
36
43
|
});
|
|
37
44
|
|
|
38
|
-
it('без
|
|
39
|
-
|
|
45
|
+
it('без portraits → картинок нет, старые поля не подставляются', () => {
|
|
46
|
+
const result = cleanModelCard(makeRaw() as any);
|
|
47
|
+
expect(result.primaryImageUrl).toBe('');
|
|
48
|
+
expect(result.primaryImageSmallUrl).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('вариант выбирается по теме', () => {
|
|
52
|
+
const file = (webp: string) => ({ width: 1, height: 1, webp, avif: `${webp}.avif` });
|
|
53
|
+
const raw = makeRaw({
|
|
54
|
+
portraits: { dark: { tall: file('dark') }, light: { tall: file('light') } },
|
|
55
|
+
});
|
|
56
|
+
expect(cleanModelCard(raw as any, 'light').primaryImageUrl).toBe('light');
|
|
57
|
+
expect(cleanModelCard(raw as any, 'dark').primaryImageUrl).toBe('dark');
|
|
40
58
|
});
|
|
41
59
|
|
|
42
60
|
it('параметры: guy_type, ethnicity, age', () => {
|
|
@@ -9,6 +9,7 @@ describe('cleanModelInfo', () => {
|
|
|
9
9
|
social: '@model',
|
|
10
10
|
videos_count: 50,
|
|
11
11
|
video_thumb_urls: { webp: { '320x180': 'https://img.com/s.webp' } },
|
|
12
|
+
portraits: { dark: { square: { width: 256, height: 256, webp: 'https://cdn/sq.webp', avif: 'https://cdn/sq.avif' } } },
|
|
12
13
|
parameters: 'height: 170',
|
|
13
14
|
long_description: 'long desc',
|
|
14
15
|
bio: 'bio text',
|
|
@@ -28,7 +29,8 @@ describe('cleanModelInfo', () => {
|
|
|
28
29
|
expect(result.description).toBe('desc');
|
|
29
30
|
expect(result.social).toBe('@model');
|
|
30
31
|
expect(result.videosCount).toBe(50);
|
|
31
|
-
expect(result.img).toBe('https://
|
|
32
|
+
expect(result.img).toBe('https://cdn/sq.webp');
|
|
33
|
+
expect(result.imgAvifUrl).toBe('https://cdn/sq.avif');
|
|
32
34
|
expect(result.parameters).toBe('height: 170');
|
|
33
35
|
expect(result.fullDescription).toBe('long desc');
|
|
34
36
|
expect(result.relatedNames).toEqual(['Model B']);
|
|
@@ -1,34 +1,42 @@
|
|
|
1
1
|
import type { IRawModelCard, IModelCard, IModelParameter } from '../../../types';
|
|
2
2
|
import { ThumbSize } from '../../constants/thumb-size';
|
|
3
|
+
import { pickPortraitVariant } from '../pick-portrait-variant';
|
|
3
4
|
|
|
4
5
|
const parameterNames = ['guy_type', 'ethnicity'];
|
|
5
6
|
const firstValue = (card: IRawModelCard, name: string) => card.parameters?.find(item => item.name === name)?.values?.[0];
|
|
6
7
|
const age = (card: IRawModelCard) => firstValue(card, 'age')?.title;
|
|
7
8
|
|
|
8
9
|
/** Нормализует сырую карточку модели к типу IModelCard, включая параметры (возраст, рост, вес) и страну. */
|
|
9
|
-
export const cleanModelCard = (card: IRawModelCard): IModelCard =>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
10
|
+
export const cleanModelCard = (card: IRawModelCard, theme?: string): IModelCard => {
|
|
11
|
+
// `tall` идёт в карточку каталога, `square` — в круглые аватары, мини-карточки и поиск.
|
|
12
|
+
const portrait = pickPortraitVariant(card.portraits, theme);
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
title: card.title || '',
|
|
16
|
+
videosCount: card.videos_count || 0,
|
|
17
|
+
thumbUrl: card.video_thumb_urls?.webp?.[ThumbSize.Small] || '',
|
|
18
|
+
primaryImageUrl: portrait?.tall?.webp || '',
|
|
19
|
+
primaryImageAvifUrl: portrait?.tall?.avif,
|
|
20
|
+
primaryImageSmallUrl: portrait?.square?.webp,
|
|
21
|
+
primaryImageSmallAvifUrl: portrait?.square?.avif,
|
|
22
|
+
guid: card.guid || '',
|
|
23
|
+
// Страна лежит в parameters под именем nationality_iso_codes: `name` — код ISO-2 (`us`),
|
|
24
|
+
// он же имя файла флага, `title` — читаемое название. Одноимённое поле верхнего уровня
|
|
25
|
+
// в ответе списка всегда null, параметра `birthplace_country` там нет вовсе.
|
|
26
|
+
country: {
|
|
27
|
+
title: firstValue(card, 'nationality_iso_codes')?.title || '',
|
|
28
|
+
name: firstValue(card, 'nationality_iso_codes')?.name?.toLowerCase() || '',
|
|
29
|
+
},
|
|
30
|
+
parameters: [
|
|
31
|
+
...(card.parameters?.filter(item => parameterNames.includes(item.name) && item.values?.length).map((item: IModelParameter) => ({
|
|
32
|
+
title: item.name,
|
|
33
|
+
value: item.values?.[0]?.title,
|
|
34
|
+
})) ?? []),
|
|
35
|
+
...(age(card) ? [{
|
|
36
|
+
title: 'age',
|
|
37
|
+
value: age(card),
|
|
38
|
+
}] : []),
|
|
39
|
+
],
|
|
40
|
+
lastVideoAddedAt: card.last_video_added_at,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import type { IRawModelInfo, ICardInfo } from '../../../types';
|
|
2
|
-
import {
|
|
2
|
+
import { pickPortraitVariant } from '../pick-portrait-variant';
|
|
3
3
|
|
|
4
4
|
/** Нормализует сырые данные страницы модели к типу ICardInfo. */
|
|
5
|
-
export const cleanModelInfo = (card: IRawModelInfo): ICardInfo =>
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
5
|
+
export const cleanModelInfo = (card: IRawModelInfo, theme?: string): ICardInfo => {
|
|
6
|
+
// В шапке страницы аватар круглый, поэтому берём квадратный кроп.
|
|
7
|
+
const portrait = pickPortraitVariant(card.portraits, theme);
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
title: card.title || '',
|
|
11
|
+
description: card.description || '',
|
|
12
|
+
social: card.social || undefined,
|
|
13
|
+
videosCount: card.videos_count || 0,
|
|
14
|
+
img: portrait?.square?.webp || '',
|
|
15
|
+
imgAvifUrl: portrait?.square?.avif,
|
|
16
|
+
parameters: card.parameters || [],
|
|
17
|
+
fullDescription: card.long_description || '',
|
|
18
|
+
relatedNames: card.relatedNames || [],
|
|
19
|
+
akaNames: card.aka_names?.join(', ') || '',
|
|
20
|
+
birthSummary: card.birth_summary || '',
|
|
21
|
+
nationalityIsoCodes: card.nationality_iso_codes || [],
|
|
22
|
+
seo: card.seo || null,
|
|
23
|
+
};
|
|
24
|
+
};
|
|
@@ -5,6 +5,7 @@ describe('convertModelToRoundCard', () => {
|
|
|
5
5
|
it('конвертирует модель в round card', () => {
|
|
6
6
|
const model = {
|
|
7
7
|
title: 'Model Name',
|
|
8
|
+
primaryImageSmallUrl: 'https://img.com/square.webp',
|
|
8
9
|
primaryImageUrl: 'https://img.com/primary.jpg',
|
|
9
10
|
thumbUrl: 'https://img.com/thumb.jpg',
|
|
10
11
|
videosCount: 10,
|
|
@@ -13,25 +14,24 @@ describe('convertModelToRoundCard', () => {
|
|
|
13
14
|
};
|
|
14
15
|
const result = convertModelToRoundCard(model);
|
|
15
16
|
expect(result.title).toBe('Model Name');
|
|
16
|
-
expect(result.primaryImageUrl).toBe('https://img.com/
|
|
17
|
+
expect(result.primaryImageUrl).toBe('https://img.com/square.webp');
|
|
17
18
|
expect(result.videosCount).toBe(10);
|
|
18
19
|
expect(result.link).toBe('/models/model-name');
|
|
19
20
|
});
|
|
20
21
|
|
|
21
|
-
it('
|
|
22
|
+
it('вертикальный портрет в круглую карточку НЕ подставляется', () => {
|
|
22
23
|
const model = {
|
|
23
24
|
title: 'Test',
|
|
24
|
-
primaryImageUrl: '',
|
|
25
|
+
primaryImageUrl: 'https://img.com/tall.webp',
|
|
25
26
|
thumbUrl: 'https://img.com/thumb.jpg',
|
|
26
27
|
videosCount: 5,
|
|
27
28
|
guid: 'm-2',
|
|
28
29
|
parameters: [],
|
|
29
30
|
};
|
|
30
|
-
|
|
31
|
-
expect(result.primaryImageUrl).toBe('https://img.com/thumb.jpg');
|
|
31
|
+
expect(convertModelToRoundCard(model).primaryImageUrl).toBe('');
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
it('без
|
|
34
|
+
it('без картинок → пустая строка', () => {
|
|
35
35
|
const model = {
|
|
36
36
|
title: 'Test',
|
|
37
37
|
primaryImageUrl: '',
|
|
@@ -44,21 +44,7 @@ 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', () => {
|
|
47
|
+
it('без мелкой ступени → undefined', () => {
|
|
62
48
|
const model = {
|
|
63
49
|
title: 'Test',
|
|
64
50
|
primaryImageUrl: 'url',
|
|
@@ -67,7 +53,7 @@ describe('convertModelToRoundCard', () => {
|
|
|
67
53
|
guid: 'm-6',
|
|
68
54
|
parameters: [],
|
|
69
55
|
};
|
|
70
|
-
expect(convertModelToRoundCard(model).
|
|
56
|
+
expect(convertModelToRoundCard(model).primaryImageAvifUrl).toBeUndefined();
|
|
71
57
|
});
|
|
72
58
|
|
|
73
59
|
it('slug с пробелами и заглавными', () => {
|
|
@@ -5,10 +5,9 @@ import { convertString } from './convert-string';
|
|
|
5
5
|
export function convertModelToRoundCard(model: IModelCard): IRoundCard {
|
|
6
6
|
return {
|
|
7
7
|
title: model.title,
|
|
8
|
-
// Круглому аватару
|
|
9
|
-
primaryImageUrl: model.primaryImageSmallUrl ||
|
|
10
|
-
|
|
11
|
-
primaryImageAvifUrl: model.primaryImageSmallAvifUrl || model.primaryImageAvifUrl,
|
|
8
|
+
// Круглому аватару нужен квадратный кроп, вертикальный портрет туда не годится.
|
|
9
|
+
primaryImageUrl: model.primaryImageSmallUrl || '',
|
|
10
|
+
primaryImageAvifUrl: model.primaryImageSmallAvifUrl,
|
|
12
11
|
videosCount: model.videosCount,
|
|
13
12
|
link: `/models/${convertString().toSlug(model.title)}`,
|
|
14
13
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IRawPortraits, IRawPortraitVariant } from '../../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Выбирает вариант портрета из карты, которую отдаёт API. Ключ — фон съёмки.
|
|
5
|
+
*
|
|
6
|
+
* Клинеры работают в BFF на сервере, где темы сайта нет под рукой, поэтому порядок такой:
|
|
7
|
+
* запрошенный вариант, затем `dark`, затем первый попавшийся. Пока API отдаёт один вариант,
|
|
8
|
+
* этого достаточно; если начнёт отдавать несколько и светлой сборке понадобится свой —
|
|
9
|
+
* тему придётся прокинуть в хендлеры через `runtimeConfig.public`, как сделано с локалями.
|
|
10
|
+
*/
|
|
11
|
+
export const pickPortraitVariant = (portraits?: IRawPortraits, preferred?: string): IRawPortraitVariant | undefined => {
|
|
12
|
+
if (!portraits) return undefined;
|
|
13
|
+
|
|
14
|
+
if (preferred && portraits[preferred]) return portraits[preferred];
|
|
15
|
+
if (portraits['dark']) return portraits['dark'];
|
|
16
|
+
|
|
17
|
+
return Object.values(portraits)[0];
|
|
18
|
+
};
|
|
@@ -2,6 +2,7 @@ import { cleanModelCard, getUrlWithProxiedParams } from '../../../runtime';
|
|
|
2
2
|
import type { IRawModelCard, IModelCard } from '../../../types';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const url = getUrlWithProxiedParams(`${apiUrl}/v2/models/search`, event);
|
|
7
8
|
|
|
@@ -14,7 +15,7 @@ export default defineEventHandler(async (event) => {
|
|
|
14
15
|
|
|
15
16
|
const items: IRawModelCard[] = rawData.data?.items || [];
|
|
16
17
|
|
|
17
|
-
const cleanedItems: IModelCard[] = items.map(cleanModelCard);
|
|
18
|
+
const cleanedItems: IModelCard[] = items.map(card => cleanModelCard(card, theme));
|
|
18
19
|
|
|
19
20
|
return {
|
|
20
21
|
currentPage: rawData.data?.current_page,
|
|
@@ -2,6 +2,7 @@ import { cleanModelCard, getUrlWithProxiedParams } from '../../../../runtime';
|
|
|
2
2
|
import type { IRawModelCard, IModelCard } from '../../../../types';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const category = getRouterParam(event, 'category');
|
|
7
8
|
const url = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-category/${category}`, event);
|
|
@@ -15,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
|
|
15
16
|
|
|
16
17
|
const items: IRawModelCard[] = rawData.data?.items || [];
|
|
17
18
|
|
|
18
|
-
const cleanedItems: IModelCard[] = items.map(cleanModelCard);
|
|
19
|
+
const cleanedItems: IModelCard[] = items.map(card => cleanModelCard(card, theme));
|
|
19
20
|
|
|
20
21
|
return {
|
|
21
22
|
currentPage: rawData.data?.current_page,
|
|
@@ -2,6 +2,7 @@ import { cleanModelCard, getUrlWithProxiedParams } from '../../../../runtime';
|
|
|
2
2
|
import type { IRawModelCard, IModelCard } from '../../../../types';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const channel = getRouterParam(event, 'channel');
|
|
7
8
|
const url = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-channel/${channel}`, event);
|
|
@@ -15,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
|
|
15
16
|
|
|
16
17
|
const items: IRawModelCard[] = rawData.data?.items || [];
|
|
17
18
|
|
|
18
|
-
const cleanedItems: IModelCard[] = items.map(cleanModelCard);
|
|
19
|
+
const cleanedItems: IModelCard[] = items.map(card => cleanModelCard(card, theme));
|
|
19
20
|
|
|
20
21
|
return {
|
|
21
22
|
currentPage: rawData.data?.current_page,
|
|
@@ -2,12 +2,13 @@ import { cleanModelCard, getUrlWithProxiedParams } from '../../../runtime';
|
|
|
2
2
|
import type { IRawModelCard, IModelCard, PaginatedResponse } from '../../../types';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const url = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-first-letter`, event);
|
|
7
8
|
|
|
8
9
|
const data = await cachedApiFetch<PaginatedResponse<IRawModelCard>>(url, event);
|
|
9
10
|
|
|
10
|
-
const cleanedItems: IModelCard[] = (data.items || []).map(cleanModelCard);
|
|
11
|
+
const cleanedItems: IModelCard[] = (data.items || []).map(card => cleanModelCard(card, theme));
|
|
11
12
|
|
|
12
13
|
return {
|
|
13
14
|
currentPage: data.current_page,
|
|
@@ -2,6 +2,7 @@ import { cleanModelCard, getUrlWithProxiedParams } from '../../../runtime';
|
|
|
2
2
|
import type { IModelFilter, IRawModelCard, IModelCard, IPageSeo } from '../../../types';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const url = getUrlWithProxiedParams(`${apiUrl}/v2/models/filter`, event);
|
|
7
8
|
|
|
@@ -19,7 +20,7 @@ export default defineEventHandler(async (event) => {
|
|
|
19
20
|
const filters: IModelFilter[] = rawData?.data?.filters || [];
|
|
20
21
|
const seo: IPageSeo | null = rawData?.data?.seo || null;
|
|
21
22
|
|
|
22
|
-
const cleanedItems: IModelCard[] = items && items.length > 0 ? items.map(cleanModelCard) : [];
|
|
23
|
+
const cleanedItems: IModelCard[] = items && items.length > 0 ? items.map(card => cleanModelCard(card, theme)) : [];
|
|
23
24
|
|
|
24
25
|
return {
|
|
25
26
|
currentPage: rawData?.data?.current_page,
|
|
@@ -2,6 +2,7 @@ import { cleanModelCard, getUrlWithProxiedParams } from '../../../../runtime';
|
|
|
2
2
|
import type { IRawModelCard, IModelCard } from '../../../../types';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const tag = getRouterParam(event, 'tag');
|
|
7
8
|
const url = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-tag/${tag}`, event);
|
|
@@ -15,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
|
|
15
16
|
|
|
16
17
|
const items: IRawModelCard[] = rawData.data?.items || [];
|
|
17
18
|
|
|
18
|
-
const cleanedItems: IModelCard[] = items.map(cleanModelCard);
|
|
19
|
+
const cleanedItems: IModelCard[] = items.map(card => cleanModelCard(card, theme));
|
|
19
20
|
|
|
20
21
|
return {
|
|
21
22
|
currentPage: rawData.data?.current_page,
|
|
@@ -2,6 +2,7 @@ import { cleanModelCard, getUrlWithProxiedParams } from '../../../runtime';
|
|
|
2
2
|
import type { IRawModelCard, IModelCard } from '../../../types';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const url = getUrlWithProxiedParams(`${apiUrl}/v2/models/related`, event);
|
|
7
8
|
|
|
@@ -14,7 +15,7 @@ export default defineEventHandler(async (event) => {
|
|
|
14
15
|
|
|
15
16
|
const items: IRawModelCard[] = rawData.data?.items || [];
|
|
16
17
|
|
|
17
|
-
const cleanedItems: IModelCard[] = items.map(cleanModelCard);
|
|
18
|
+
const cleanedItems: IModelCard[] = items.map(card => cleanModelCard(card, theme));
|
|
18
19
|
|
|
19
20
|
return {
|
|
20
21
|
currentPage: rawData.data?.current_page,
|
|
@@ -2,12 +2,13 @@ import { cleanModelCard, getUrlWithProxiedParams } from '../../../runtime';
|
|
|
2
2
|
import type { IRawModelCard, IModelCard, PaginatedResponse } from '../../../types';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const url = getUrlWithProxiedParams(`${apiUrl}/v2/models/top`, event);
|
|
7
8
|
|
|
8
9
|
const data = await cachedApiFetch<PaginatedResponse<IRawModelCard>>(url, event);
|
|
9
10
|
|
|
10
|
-
const cleanedItems: IModelCard[] = (data?.items || []).map(cleanModelCard);
|
|
11
|
+
const cleanedItems: IModelCard[] = (data?.items || []).map(card => cleanModelCard(card, theme));
|
|
11
12
|
|
|
12
13
|
return {
|
|
13
14
|
currentPage: data?.current_page,
|
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',
|
|
72
|
-
{ title: 'Jax Thirio',
|
|
73
|
-
{ title: 'Austin Young',
|
|
74
|
-
{ title: 'Devin Franco',
|
|
75
|
-
{ title: 'Dakota Lovell',
|
|
76
|
-
{ title: 'Jax Thirio',
|
|
77
|
-
{ title: 'Austin Young',
|
|
78
|
-
{ title: 'Devin Franco',
|
|
79
|
-
{ title: 'Dakota Lovell',
|
|
80
|
-
{ title: 'Dakota Lovell',
|
|
81
|
-
{ title: 'Dakota Lovell',
|
|
82
|
-
{ title: 'Dakota Lovell',
|
|
83
|
-
{ title: 'Jax Thirio',
|
|
84
|
-
{ title: 'Austin Young',
|
|
85
|
-
{ title: 'Devin Franco',
|
|
71
|
+
{ title: 'Dakota Lovell', 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', 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', 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', 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', 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', 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', 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', 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', 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', 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', 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', 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', 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', 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', 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 = [
|
|
@@ -136,13 +136,13 @@ const PLAYLISTS = [
|
|
|
136
136
|
export function getLibraryModelsStub(): LibraryModelStub[] {
|
|
137
137
|
const stamp = now();
|
|
138
138
|
|
|
139
|
-
return MODELS.map(({
|
|
139
|
+
return MODELS.map(({ updatedAgo, videoOffset, ...model }) => {
|
|
140
140
|
const guid = toSlug(model.title);
|
|
141
141
|
|
|
142
142
|
return {
|
|
143
143
|
...model,
|
|
144
144
|
guid,
|
|
145
|
-
avatarUrl:
|
|
145
|
+
avatarUrl: '',
|
|
146
146
|
lastVideoAddedAt: stamp - updatedAgo,
|
|
147
147
|
videos: buildVideos(guid, videoOffset, model.newCount),
|
|
148
148
|
};
|
package/types/card-info.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { IParameterModel } from './parameter-model';
|
|
2
2
|
import type { IChipsItem } from './chips-item';
|
|
3
|
-
import type { IModelImageUrls } from './model-image-urls';
|
|
4
3
|
import type { IPageSeo } from './page-seo';
|
|
5
4
|
|
|
6
5
|
export interface ICardInfo {
|
|
@@ -10,8 +9,6 @@ export interface ICardInfo {
|
|
|
10
9
|
img?: string;
|
|
11
10
|
/** Тот же кадр в avif. Есть — рендерится через `<picture>`, `img` фолбэком. */
|
|
12
11
|
imgAvifUrl?: string;
|
|
13
|
-
/** Ладдер портрета под srcset. Нет — рендерится один `img`. */
|
|
14
|
-
imgUrls?: IModelImageUrls;
|
|
15
12
|
guid?: string;
|
|
16
13
|
fullDescription?: string
|
|
17
14
|
miniCardTitle?: string
|
package/types/index.d.ts
CHANGED
|
@@ -2,9 +2,8 @@ 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';
|
|
6
5
|
export * from './raw/raw-thumbs-urls.d.ts';
|
|
7
|
-
export * from './raw/raw-
|
|
6
|
+
export * from './raw/raw-portraits.d.ts';
|
|
8
7
|
export * from './raw/raw-category-info.d.ts';
|
|
9
8
|
export * from './raw/raw-category-card.d.ts';
|
|
10
9
|
export * from './raw/raw-channel-card.d.ts';
|
package/types/model-card.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { IModelImageUrls } from './model-image-urls';
|
|
2
|
-
|
|
3
1
|
export interface IModelCard {
|
|
4
2
|
title: string;
|
|
5
3
|
videosCount: number;
|
|
@@ -10,8 +8,6 @@ export interface IModelCard {
|
|
|
10
8
|
/** Мелкая ступень 300w: круглые аватары, мини-карточки, подсказки поиска. */
|
|
11
9
|
primaryImageSmallUrl?: string;
|
|
12
10
|
primaryImageSmallAvifUrl?: string;
|
|
13
|
-
/** Ладдер портрета под srcset. Нет — карточка рендерит один `primaryImageUrl`. */
|
|
14
|
-
primaryImageUrls?: IModelImageUrls;
|
|
15
11
|
guid: string;
|
|
16
12
|
age?: string;
|
|
17
13
|
country?: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IRawThumbUrls } from './raw-thumbs-urls';
|
|
2
|
-
import type {
|
|
2
|
+
import type { IRawPortraits } from './raw-portraits';
|
|
3
3
|
import type { IModelParameter } from '~/types';
|
|
4
4
|
|
|
5
5
|
export interface IRawModelCard {
|
|
@@ -9,7 +9,7 @@ export interface IRawModelCard {
|
|
|
9
9
|
guid: string;
|
|
10
10
|
likes: number;
|
|
11
11
|
primary_image_url: string;
|
|
12
|
-
|
|
12
|
+
portraits?: IRawPortraits;
|
|
13
13
|
number_of_videos_in_recent_days: number;
|
|
14
14
|
orientation: string;
|
|
15
15
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IRawThumbUrls } from './raw-thumbs-urls';
|
|
2
|
+
import type { IRawPortraits } from './raw-portraits';
|
|
2
3
|
import type { IParameterModel, IPageSeo } from '~/types';
|
|
3
4
|
|
|
4
5
|
export interface IRawModelInfo {
|
|
@@ -29,6 +30,7 @@ export interface IRawModelInfo {
|
|
|
29
30
|
thumb_number: number;
|
|
30
31
|
views: number;
|
|
31
32
|
primary_image_md5: string;
|
|
33
|
+
portraits?: IRawPortraits;
|
|
32
34
|
rank: number;
|
|
33
35
|
number_of_videos_in_recent_days: number;
|
|
34
36
|
video_thumb_urls: IRawThumbUrls;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Один файл портрета: пара форматов и габариты. */
|
|
2
|
+
export interface IRawPortraitFile {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
webp: string;
|
|
6
|
+
avif: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Набор портретов одного варианта: вертикальный под карточку каталога и квадратный кроп по
|
|
11
|
+
* лицу под круглые аватары, мини-карточки и подсказки поиска.
|
|
12
|
+
*/
|
|
13
|
+
export interface IRawPortraitVariant {
|
|
14
|
+
tall?: IRawPortraitFile;
|
|
15
|
+
square?: IRawPortraitFile;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Варианты портретов, ключ — фон съёмки (`dark` и т.п.). API может вернуть несколько; какой
|
|
20
|
+
* из них наш, решает `pickPortraitVariant`.
|
|
21
|
+
*/
|
|
22
|
+
export type IRawPortraits = Record<string, IRawPortraitVariant>;
|
package/types/round-card.d.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import type { IModelImageUrls } from './model-image-urls';
|
|
2
|
-
|
|
3
1
|
export interface IRoundCard {
|
|
4
2
|
title: string
|
|
5
3
|
primaryImageUrl: string
|
|
6
4
|
/** Тот же портрет в avif. Есть — карточка отдаёт его через `<picture>`, webp фолбэком. */
|
|
7
5
|
primaryImageAvifUrl?: string
|
|
8
|
-
/** Ладдер портрета под srcset. Нет — карточка рендерит один `primaryImageUrl`. */
|
|
9
|
-
primaryImageUrls?: IModelImageUrls
|
|
10
6
|
videosCount: number
|
|
11
7
|
link: string
|
|
12
8
|
/** Новинки с прошлого захода — бейдж на аватаре (страница подписок) */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { IRawPortraits } from './raw/raw-portraits';
|
|
2
2
|
|
|
3
3
|
export interface ISearchTopModelsType {
|
|
4
4
|
videosCount?: number
|
|
@@ -6,6 +6,6 @@ export interface ISearchTopModelsType {
|
|
|
6
6
|
title: string
|
|
7
7
|
primaryImageUrl?: string
|
|
8
8
|
primaryImageAvifUrl?: string
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/** Приходит из multi-suggest как есть: там общий snake→camel, отдельного клинера нет. */
|
|
10
|
+
portraits?: IRawPortraits
|
|
11
11
|
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
export interface ITestModelPortrait {
|
|
2
|
-
primaryImageUrl: string;
|
|
3
|
-
thumbUrl: string;
|
|
4
|
-
primaryImageAvifUrl?: string;
|
|
5
|
-
primaryImageSmallUrl?: string;
|
|
6
|
-
primaryImageSmallAvifUrl?: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/** Модели из тестовой поставки нарезчика: `public/img/models/<guid>{,-640,-256}.{png,webp,avif}`. */
|
|
10
|
-
const PORTRAIT_GUIDS = [
|
|
11
|
-
'00003fff-0aa5-4e65-9356-7e01358408fe',
|
|
12
|
-
'0000cca2-8ec5-4c12-950f-4460920805b9',
|
|
13
|
-
'0002b6da-f82d-484a-aa0b-9a7f9ecb11da',
|
|
14
|
-
'00038fca-1453-40d8-8ace-66ea034c804e',
|
|
15
|
-
'0004202c-10a1-461b-9f80-e3c0fafdff04',
|
|
16
|
-
'0004c061-1393-42d0-8582-29952591129f',
|
|
17
|
-
'0006f78f-e668-4c35-9370-a936565780a4',
|
|
18
|
-
'0006fe81-996a-4ab3-b5b9-27285dd1c2cd',
|
|
19
|
-
'00072f5d-6068-4fa0-a22f-16b07d6f4e6b',
|
|
20
|
-
'000785cd-9500-4915-a7f4-836dda420f34',
|
|
21
|
-
];
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Тестовые портреты моделей:
|
|
25
|
-
*
|
|
26
|
-
* - `?img=png` — исходники 1024×1536;
|
|
27
|
-
* - `?img=444` — наш avif в полной цветности 4:4:4 при том же качестве яркости, что у поставки
|
|
28
|
-
* нарезчика (она в 4:2:0). Сравнение стоит вести по цвету: губы, румянец, переходы кожи;
|
|
29
|
-
* - без параметра — нарезка по `DOCS/MODEL-IMAGES.md`: один 640w, avif через `<picture>`.
|
|
30
|
-
*
|
|
31
|
-
* Две ширины, выбирает их КОМПОНЕНТ, а не браузер: srcset мы не ставим. 640w — портрет в
|
|
32
|
-
* карточку сетки, 256w — квадратный кроп по лицу в круглые аватары, мини-карточки и подсказки
|
|
33
|
-
* поиска.
|
|
34
|
-
*
|
|
35
|
-
* Ступеней через srcset НЕ делаем. Ступень мельче слота заведомо хуже полного файла: её портит
|
|
36
|
-
* двойной пересчёт (ресайз нарезчика плюс браузерный), а её артефакты показываются почти 1:1,
|
|
37
|
-
* тогда как 640w в слоте карточки ужимается в 2.5 раза и они размазываются. Проверено на
|
|
38
|
-
* DPR1-мониторе: ладдер мылит, один файл нет.
|
|
39
|
-
*
|
|
40
|
-
* Пробовали поднять ширину до 800 и 1024 при том же весе: на DPR1-мониторе рядом с исходником
|
|
41
|
-
* они чуть чище. Но без эталона под рукой разницу не поймать, а у пользователя эталона нет —
|
|
42
|
-
* остались на 640.
|
|
43
|
-
*
|
|
44
|
-
* Читается один раз на монтировании, поэтому переключение — перезагрузкой, а не навигацией
|
|
45
|
-
* по query. Для замера сети это и нужно: он честен только с чистого захода.
|
|
46
|
-
*/
|
|
47
|
-
export function useTestModelImages() {
|
|
48
|
-
const format = String(useRoute().query['img'] || '');
|
|
49
|
-
|
|
50
|
-
const portraits: ITestModelPortrait[] = PORTRAIT_GUIDS.map((guid) => {
|
|
51
|
-
const base = `/img/models/${guid}`;
|
|
52
|
-
|
|
53
|
-
if (format === 'png') {
|
|
54
|
-
return { primaryImageUrl: `${base}.png`, thumbUrl: `${base}.png` };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return {
|
|
58
|
-
primaryImageUrl: `${base}-640.webp`,
|
|
59
|
-
thumbUrl: `${base}-256.webp`,
|
|
60
|
-
primaryImageSmallUrl: `${base}-256.webp`,
|
|
61
|
-
primaryImageAvifUrl: format === '444' ? `${base}-640-444.avif` : `${base}-640.avif`,
|
|
62
|
-
primaryImageSmallAvifUrl: format === '444' ? `${base}-256-444.avif` : `${base}-256.avif`,
|
|
63
|
-
};
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
const portraitAt = (index: number) => portraits[index % portraits.length]!;
|
|
67
|
-
|
|
68
|
-
return { portraits, portraitAt };
|
|
69
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ладдер портрета модели. Ключ — ширина в пикселях, высота не фиксирована.
|
|
3
|
-
*
|
|
4
|
-
* Портреты НЕ приводятся к 3:4 нарезкой — обрезает `object-fit` в карточке. Предварительный
|
|
5
|
-
* кроп менял габариты файла, а вместе с ними и цепочку уменьшения в браузере, и картинка
|
|
6
|
-
* заметно мылила при тех же самых пикселях. Проверено на живой вёрстке.
|
|
7
|
-
*
|
|
8
|
-
* Только webp: avif на портретах проигрывал по точности и почти не выигрывал по весу, а
|
|
9
|
-
* держать ради него `<picture>` в `UiImg` не окупалось.
|
|
10
|
-
*/
|
|
11
|
-
export interface IModelImageUrls {
|
|
12
|
-
avif?: Record<string, string>;
|
|
13
|
-
webp: Record<string, string>;
|
|
14
|
-
}
|