itube-specs 0.0.981 → 0.0.983

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.
Files changed (41) hide show
  1. package/components/card/model-mini.vue +3 -8
  2. package/components/card/model.vue +1 -3
  3. package/components/card/round.vue +0 -2
  4. package/components/info/main.vue +1 -4
  5. package/components/search/item.vue +13 -4
  6. package/components/ui/img.vue +3 -5
  7. package/components/view/video.vue +2 -5
  8. package/nuxt.config.ts +3 -0
  9. package/package.json +1 -1
  10. package/pages/models/[slug]/index.vue +0 -8
  11. package/pages/models/index.vue +1 -14
  12. package/runtime/index.ts +1 -0
  13. package/runtime/utils/__tests__/pick-portrait-variant.test.ts +25 -0
  14. package/runtime/utils/cleaners/__tests__/clean-model-card.test.ts +25 -7
  15. package/runtime/utils/cleaners/__tests__/clean-model-info.test.ts +3 -1
  16. package/runtime/utils/cleaners/clean-model-card.ts +34 -26
  17. package/runtime/utils/cleaners/clean-model-info.ts +21 -15
  18. package/runtime/utils/converters/__tests__/convert-model-to-round-card.test.ts +8 -22
  19. package/runtime/utils/converters/convert-model-to-round-card.ts +3 -4
  20. package/runtime/utils/pick-portrait-variant.ts +18 -0
  21. package/server/api/models/get-info-by-name.get.ts +1 -1
  22. package/server/api/models/get-models-by-phrase.get.ts +2 -1
  23. package/server/api/models/list-by-category/[category].get.ts +2 -1
  24. package/server/api/models/list-by-channel/[channel].get.ts +2 -1
  25. package/server/api/models/list-by-first-letter.get.ts +2 -1
  26. package/server/api/models/list-by-niche.post.ts +2 -1
  27. package/server/api/models/list-by-tag/[tag].get.ts +2 -1
  28. package/server/api/models/related.get.ts +2 -1
  29. package/server/api/models/top.get.ts +2 -1
  30. package/stubs/library-follows.ts +17 -17
  31. package/types/card-info.d.ts +0 -3
  32. package/types/index.d.ts +1 -2
  33. package/types/model-card.d.ts +0 -4
  34. package/types/raw/raw-model-card.d.ts +2 -2
  35. package/types/raw/raw-model-info.d.ts +2 -0
  36. package/types/raw/raw-portraits.d.ts +22 -0
  37. package/types/round-card.d.ts +0 -4
  38. package/types/search-top-models.d.ts +3 -3
  39. package/composables/use-test-model-images.ts +0 -69
  40. package/types/model-image-urls.d.ts +0 -14
  41. 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, крупнее мелкой ступени сюда не надо: при DPR3 нужно всего 144. */
58
+ /** Аватар 48px круглый — нужен квадратный кроп, вертикальный портрет сюда не годится. */
60
59
  const currentImage = computed(() => {
61
60
  if (imageError.value) return fallbackImage;
62
- return props.card.primaryImageSmallUrl || props.card.primaryImageUrl || props.card.thumbUrl || fallbackImage;
61
+ return props.card.primaryImageSmallUrl || fallbackImage;
63
62
  });
64
63
 
65
- const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
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 || props.card.thumbUrl || fallbackImage;
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() {
@@ -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,
@@ -392,6 +388,7 @@ const socialItems = computed<ILinkItem[]>(() => {
392
388
  margin-top: 12px;
393
389
  line-height: 1.625;
394
390
  color: $info-description;
391
+ grid-column: 2;
395
392
 
396
393
  @include to-br(sm) {
397
394
  grid-column: 1/-1;
@@ -10,9 +10,8 @@
10
10
  >
11
11
  <UiImg
12
12
  v-if="isModel"
13
- :src="item?.primaryImageUrl || `/img/model-placeholder.webp`"
14
- :avif-src="item?.primaryImageAvifUrl"
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
 
@@ -24,7 +24,7 @@
24
24
  </template>
25
25
 
26
26
  <script setup lang="ts">
27
- import type { IThumbUrls, IModelImageUrls } from '../../types';
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.imageUrls?.webp) || buildSrcset(props.urls?.webp));
73
+ const computedSrcset = computed(() => buildSrcset(props.urls?.webp));
76
74
 
77
75
  /** Ладдер старше одиночного `avifSrc`: нужен, когда ступени есть и в avif-ветке. */
78
- const avifSrcset = computed(() => buildSrcset(props.imageUrls?.avif) || props.avifSrc);
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, index)"
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
- const { portraitAt } = useTestModelImages();
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.981",
4
+ "version": "0.0.983",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -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' ]));
@@ -25,7 +25,7 @@
25
25
  </template>
26
26
 
27
27
  <script setup lang="ts">
28
- import type { IBreadcrumbItem, IModelImageUrls } from '../../types';
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('primary_image_urls → ладдер портрета как есть, ступени произвольные', () => {
29
+ it('portraits → tall в карточку, square в аватары', () => {
30
30
  const raw = makeRaw({
31
- primary_image_urls: { webp: { 300: 'https://img.com/p-300.webp', 640: 'https://img.com/p-640.webp' } },
32
- });
33
- expect(cleanModelCard(raw as any).primaryImageUrls).toEqual({
34
- webp: { 300: 'https://img.com/p-300.webp', 640: 'https://img.com/p-640.webp' },
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('без primary_image_urls → ладдера нет', () => {
39
- expect(cleanModelCard(makeRaw() as any).primaryImageUrls).toBeUndefined();
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://img.com/s.webp');
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
- title: card.title || '',
11
- videosCount: card.videos_count || 0,
12
- thumbUrl: card.video_thumb_urls?.webp?.[ThumbSize.Small] || '',
13
- primaryImageUrl: card.primary_image_url || '',
14
- primaryImageUrls: card.primary_image_urls?.webp ? { webp: card.primary_image_urls.webp } : undefined,
15
- guid: card.guid || '',
16
- // Страна лежит в parameters под именем nationality_iso_codes: `name` — код ISO-2 (`us`),
17
- // он же имя файла флага, `title` — читаемое название. Одноимённое поле верхнего уровня
18
- // в ответе списка всегда null, параметра `birthplace_country` там нет вовсе.
19
- country: {
20
- title: firstValue(card, 'nationality_iso_codes')?.title || '',
21
- name: firstValue(card, 'nationality_iso_codes')?.name?.toLowerCase() || '',
22
- },
23
- parameters: [
24
- ...(card.parameters?.filter(item => parameterNames.includes(item.name) && item.values?.length).map((item: IModelParameter) => ({
25
- title: item.name,
26
- value: item.values?.[0]?.title,
27
- })) ?? []),
28
- ...(age(card) ? [{
29
- title: 'age',
30
- value: age(card),
31
- }] : []),
32
- ],
33
- lastVideoAddedAt: card.last_video_added_at,
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 { ThumbSize } from '../../constants/thumb-size';
2
+ import { pickPortraitVariant } from '../pick-portrait-variant';
3
3
 
4
4
  /** Нормализует сырые данные страницы модели к типу ICardInfo. */
5
- export const cleanModelInfo = (card: IRawModelInfo): ICardInfo => ({
6
- title: card.title || '',
7
- description: card.description || '',
8
- social: card.social || undefined,
9
- videosCount: card.videos_count || 0,
10
- img: card.video_thumb_urls?.webp?.[ThumbSize.Small] || '',
11
- parameters: card.parameters || [],
12
- fullDescription: card.long_description || '',
13
- relatedNames: card.relatedNames || [],
14
- akaNames: card.aka_names?.join(', ') || '',
15
- birthSummary: card.birth_summary || '',
16
- nationalityIsoCodes: card.nationality_iso_codes || [],
17
- seo: card.seo || null,
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/primary.jpg');
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('без primaryImageUrl → использует thumbUrl', () => {
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
- const result = convertModelToRoundCard(model);
31
- expect(result.primaryImageUrl).toBe('https://img.com/thumb.jpg');
31
+ expect(convertModelToRoundCard(model).primaryImageUrl).toBe('');
32
32
  });
33
33
 
34
- it('без обоих url → пустая строка', () => {
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).primaryImageUrls).toBeUndefined();
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
- // Круглому аватару крупнее мелкой ступени не нужно: 96px при DPR3 просит 288.
9
- primaryImageUrl: model.primaryImageSmallUrl || model.primaryImageUrl || model.thumbUrl || '',
10
- primaryImageUrls: model.primaryImageUrls,
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
+ };
@@ -6,5 +6,5 @@ export default defineEventHandler(async (event) => {
6
6
 
7
7
  const data = await cachedApiFetch(url, event, 300);
8
8
 
9
- return cleanModelInfo(data);
9
+ return cleanModelInfo(data, useRuntimeConfig(event).public.theme);
10
10
  });
@@ -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,
@@ -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-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 },
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(({ image, updatedAgo, videoOffset, ...model }) => {
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: `/img/models/${image}`,
145
+ avatarUrl: '',
146
146
  lastVideoAddedAt: stamp - updatedAgo,
147
147
  videos: buildVideos(guid, videoOffset, model.newCount),
148
148
  };
@@ -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-model-image-urls.d.ts';
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';
@@ -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 { IRawModelImageUrls } from './raw-model-image-urls';
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
- primary_image_urls?: IRawModelImageUrls;
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>;
@@ -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 { IModelImageUrls } from './model-image-urls';
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
- /** Ладдер портрета под srcset. Нет — рендерится один `primaryImageUrl`. */
10
- primaryImageUrls?: IModelImageUrls
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
- }
@@ -1,5 +0,0 @@
1
- /** Raw портрет модели из API. Ключ — ширина в пикселях, см. `IModelImageUrls`. */
2
- export interface IRawModelImageUrls {
3
- jpeg?: Record<string, string>;
4
- webp?: Record<string, string>;
5
- }