itube-specs 0.0.994 → 0.0.995

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.
@@ -814,19 +814,23 @@ $info-grid-item-warning: $color-red-400;
814
814
  $info-grid-item-icon: $color-zinc-400;
815
815
  $info-grid-plain-text: $color-zinc-400;
816
816
 
817
- // ─── Info (model) ───
818
- $info-border: $color-zinc-800-50;
819
- $info-bg: $color-zinc-900-50;
817
+ // ─── Info ───
820
818
  $info-img-bg: $color-zinc-800;
821
819
  $info-img-border: $color-zinc-700;
822
820
  $info-description: $color-zinc-400;
823
- $info-parameters-border: $color-zinc-800-50;
824
- $info-parameters-text: $color-zinc-500;
825
- $info-parameters-text--hover: $color-zinc-300;
826
- $info-parameters-also: $color-zinc-400;
827
- $info-parameters-also-title: $color-zinc-500;
828
- $info-parameters-born: $color-zinc-400;
829
- $info-parameters-born-icon: $color-zinc-500;
821
+
822
+ // ─── Info model ───
823
+ $info-model-border: $color-zinc-800-50;
824
+ $info-model-bg: $color-zinc-900-50;
825
+ $info-model-img-bg: $color-zinc-800;
826
+ $info-model-description: $color-zinc-400;
827
+ $info-model-footer-border: $color-zinc-800-50;
828
+ $info-model-more-text: $color-zinc-500;
829
+ $info-model-more-text--hover: $color-zinc-300;
830
+ $info-model-also: $color-zinc-400;
831
+ $info-model-also-title: $color-zinc-500;
832
+ $info-model-born: $color-zinc-400;
833
+ $info-model-born-icon: $color-zinc-500;
830
834
 
831
835
  // ─── Info socials ───
832
836
  $info-socials-bg: $color-zinc-800;
@@ -15,6 +15,7 @@ $button-variant-overrides: (
15
15
  filter-reset: muted,
16
16
  playlist-button: secondary,
17
17
  info-button: secondary,
18
+ info-model-button: secondary,
18
19
  interface-button: secondary,
19
20
  );
20
21
 
@@ -840,19 +841,23 @@ $info-grid-item-warning: $color-red-400;
840
841
  $info-grid-item-icon: $color-gray-400;
841
842
  $info-grid-plain-text: $color-gray-600;
842
843
 
843
- // ─── Info (model) ───
844
- $info-border: transparent;
845
- $info-bg: $color-gray-200;
844
+ // ─── Info ───
846
845
  $info-img-bg: $color-gray-300;
847
846
  $info-img-border: transparent;
848
847
  $info-description: $color-gray-600;
849
- $info-parameters-border: $color-gray-300;
850
- $info-parameters-text: $color-gray-600;
851
- $info-parameters-text--hover: $color-gray-600;
852
- $info-parameters-also: $color-gray-600;
853
- $info-parameters-also-title: $color-gray-600;
854
- $info-parameters-born: $color-gray-600;
855
- $info-parameters-born-icon: $color-gray-600;
848
+
849
+ // ─── Info model ───
850
+ $info-model-border: transparent;
851
+ $info-model-bg: $color-gray-200;
852
+ $info-model-img-bg: $color-gray-300;
853
+ $info-model-description: $color-gray-600;
854
+ $info-model-footer-border: $color-gray-300;
855
+ $info-model-more-text: $color-gray-600;
856
+ $info-model-more-text--hover: $color-gray-600;
857
+ $info-model-also: $color-gray-600;
858
+ $info-model-also-title: $color-gray-600;
859
+ $info-model-born: $color-gray-600;
860
+ $info-model-born-icon: $color-gray-600;
856
861
 
857
862
  // ─── Info socials ───
858
863
  $info-socials-bg: $color-white;
@@ -43,7 +43,7 @@
43
43
  >
44
44
  <!-- Флаг декоративный: страна названа в `value.title` ниже -->
45
45
  <img
46
- v-if="item.name === 'nationality_iso_codes' && isoCodes"
46
+ v-if="item.name === 'nationality_iso_codes' && isoCodes?.[vIndex]"
47
47
  :src="`/icons/flags/${isoCodes[vIndex]}.svg`"
48
48
  class="info-grid__item-flag"
49
49
  width="20"
@@ -71,7 +71,7 @@ import type { IGroupedParameter, IGroupedParameterItem } from '../../types';
71
71
 
72
72
  defineProps<{
73
73
  groups: IGroupedParameter[]
74
- isoCodes?: string
74
+ isoCodes?: string[]
75
75
  }>();
76
76
 
77
77
  const PLAIN_GROUP_ITEM_NAMES = ['turnons'];
@@ -1,11 +1,6 @@
1
- <!-- Инфо-карточка: аватар, заголовок, соцсети, описание, параметры -->
1
+ <!-- Инфо-карточка канала/категории: аватар, заголовок, описание. Модель — `InfoModel` -->
2
2
  <template>
3
- <div
4
- :class="{
5
- '--model': isModel,
6
- }"
7
- class="info-main"
8
- >
3
+ <div class="info-main">
9
4
  <div
10
5
  :class="{
11
6
  '--img': card.img,
@@ -13,7 +8,7 @@
13
8
  class="info-main__content"
14
9
  >
15
10
  <UiImg
16
- v-if="card.img || isModel"
11
+ v-if="card.img"
17
12
  :src="currentImage || placeholder"
18
13
  :avif-src="currentAvifSrc"
19
14
  :alt="title"
@@ -54,89 +49,19 @@
54
49
  </div>
55
50
  </div>
56
51
 
57
- <InfoSocials
58
- v-if="socialItems && socialItems.length > 0"
59
- :items="socialItems"
60
- :known-icons="knownIcons"
61
- class="info-main__social"
62
- icon-size="16"
63
- />
64
- <p
65
- v-if="card.akaNames"
66
- class="info-main__also"
67
- >
68
- <span class="info-main__also-title">{{ $t('also_known_as') }}&nbsp;</span>
69
- <span class="info-main__also-text">{{ card.akaNames }}</span>
70
- </p>
71
- <p
72
- v-if="card.birthSummary"
73
- class="info-main__born"
74
- >
75
- <UiIcon
76
- class="info-main__born-title-icon"
77
- name="geo"
78
- size="14"
79
- />
80
- <span class="info-main__born-text">{{ card.birthSummary }}</span>
81
- <!-- Национальность нигде больше не подписана — флаг единственный её носитель,
82
- поэтому в `alt` идёт название страны, а не ISO-код. -->
83
- <img
84
- v-for="(item, index) in card.nationalityIsoCodes"
85
- :key="`nationalityIsoCodes-${index}`"
86
- :src="`/icons/flags/${item}.svg`"
87
- :alt="isoCodeToCountryName(item, locale)"
88
- class="info-main__born-flag"
89
- width="20"
90
- height="14"
91
- >
92
- </p>
93
52
  <p
94
53
  v-if="cardDescription"
95
54
  class="info-main__description"
96
55
  >{{ cardDescription }}</p>
97
56
  </div>
98
57
 
99
- <div
100
- v-if="card.parameters && card.parameters.length > 0"
101
- class="info-main__parameters"
102
- >
103
- <div
104
- v-if="isParameterShow"
105
- class="info-main__parameters-wrapper"
106
- >
107
- <InfoGrid
108
- :groups="groupedParameters"
109
- :iso-codes="card.nationalityIsoCodes"
110
- />
111
- </div>
112
- <div class="info-main__parameters-wrapper">
113
- <button
114
- class="info-main__parameters-button"
115
- type="button"
116
- @click="showParameters"
117
- >
118
- {{ isParameterShow ? t('show_less') : t('show_more') }}
119
- <UiIcon
120
- :class="{'--more': isParameterShow}"
121
- class="info-main__parameters-button-icon"
122
- name="angle-down"
123
- size="12"
124
- />
125
- </button>
126
- </div>
127
- </div>
128
-
129
58
  <ShareMain v-model="isShareOpen">
130
59
  <template
131
60
  v-if="type"
132
61
  #card
133
62
  >
134
- <CardModelMini
135
- v-if="isModel"
136
- :card="shareModelCard"
137
- />
138
63
  <CardChannelMini
139
- v-else-if="type === 'channel'"
64
+ v-if="type === 'channel'"
140
65
  :card="shareChannelCard"
141
66
  />
142
67
  <CardCategoryMini
@@ -152,44 +77,27 @@
152
77
  import { AuthSteps } from '../../runtime';
153
78
  import type {
154
79
  ICardInfo,
155
- IGroupedParameter,
156
- ILinkItem,
157
- IModelCard,
158
80
  IChannelCard,
159
81
  ICategoryCard
160
82
  } from '../../types';
161
83
  import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
162
84
  import { AuthorizationApiService } from '../../services/api/authorization.service';
163
- import { groupParametersByGroup } from '../../utils/info-main/group-parameters-by-group';
164
- import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
165
85
 
166
- const { t, locale } = useI18n();
86
+ const { t } = useI18n();
167
87
 
168
88
  const props = defineProps<{
169
89
  card: ICardInfo
170
90
  title?: string
171
- type?: 'model' | 'channel' | 'category'
91
+ type?: 'channel' | 'category'
172
92
  videosCount?: number
173
93
  }>();
174
94
 
175
- const groupedParameters = computed<IGroupedParameter[]>(() =>
176
- groupParametersByGroup(props.card.parameters ?? []),
177
- );
178
-
179
- const isModel = computed(() => props.type === 'model');
180
-
181
95
  const featureFlags = useRuntimeConfig().public.featureFlags;
182
96
 
183
97
  const isShareOpen = ref(false);
184
98
 
185
99
  const { isAuthorized } = useUser(AuthorizationApiService);
186
100
  const { openAuthPopup } = useAuthPopup();
187
- const isParameterShow = ref(false);
188
-
189
- function showParameters() {
190
- isParameterShow.value = !isParameterShow.value;
191
- }
192
-
193
101
  const authPopupText = 'register_first';
194
102
 
195
103
  /** Заглушка вместо ручки подписки: состояние живёт только в компоненте, на бэк ничего не уходит */
@@ -203,53 +111,16 @@ function onFollowClick() {
203
111
  }
204
112
  }
205
113
 
206
- const placeholders: Record<string, string> = {
207
- model: '/img/model-placeholder-square.webp',
208
- channel: '/img/logo-placeholder.svg',
209
- };
210
- /** У канала заглушка — svg, avif-пары у неё нет. */
211
- const avifPlaceholders: Record<string, string | undefined> = {
212
- model: '/img/model-placeholder-square.avif',
213
- };
214
- const placeholderType = computed(() => props.type || 'channel');
215
- const placeholder = computed(() => placeholders[ placeholderType.value ]);
114
+ /** Заглушка — svg, avif-пары у неё нет. */
115
+ const placeholder = '/img/logo-placeholder.svg';
216
116
  const imageError = ref(false);
217
117
  const isFallback = computed(() => imageError.value || !props.card.img);
218
- const currentImage = computed(() => (isFallback.value ? placeholder.value : props.card.img));
118
+ const currentImage = computed(() => (isFallback.value ? placeholder : props.card.img));
219
119
 
220
120
  /** `.info-main__img` — 80px до sm, 96px дальше. `auto` впереди — см. `UiImg`. */
221
121
  const IMG_SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
222
122
 
223
- const currentAvifSrc = computed(() => (
224
- isFallback.value ? avifPlaceholders[ placeholderType.value ] : props.card.imgAvifUrl
225
- ));
226
-
227
- function getParameter(name: string) {
228
- return props.card.parameters?.find(p => p.name === name)?.values?.[ 0 ]?.name;
229
- }
230
-
231
- /**
232
- * `IModelCard.country` — пара «код файла флага + читаемое имя», как её отдаёт
233
- * cleanModelCard. Раньше сюда уезжал целиком массив `nationalityIsoCodes`: у модели с
234
- * двумя национальностями путь к флагу выходил `/icons/flags/us,gb.svg`, а у модели без
235
- * национальностей пустой массив проходил `v-if` и давал битую картинку. Берём первый код.
236
- */
237
- const shareCountry = computed(() => {
238
- const iso = props.card.nationalityIsoCodes?.[0] || '';
239
- if (!iso) return undefined;
240
-
241
- return { name: iso, title: isoCodeToCountryName(iso, locale.value) };
242
- });
243
-
244
- const shareModelCard = computed<IModelCard>(() => ({
245
- title: props.card.title,
246
- videosCount: props.card.videosCount,
247
- thumbUrl: props.card.img || '',
248
- guid: props.card.guid || '',
249
- parameters: [],
250
- country: shareCountry.value,
251
- age: getParameter('age'),
252
- }));
123
+ const currentAvifSrc = computed(() => (isFallback.value ? undefined : props.card.imgAvifUrl));
253
124
 
254
125
  const shareChannelCard = computed<IChannelCard>(() => ({
255
126
  guid: props.card.guid || '',
@@ -270,28 +141,12 @@ const shareCategoryCard = computed<ICategoryCard>(() => ({
270
141
  videosCount: props.card.videosCount,
271
142
  }));
272
143
 
273
- const knownIcons = ['facebook', 'instagram', 'tiktok', 'twitter', 'x', 'youtube', 'snapchat'];
274
-
275
144
  const cardDescription = computed(() => props.card?.description);
276
145
 
277
146
  const title = computed(() => props.title || props.card?.title);
278
-
279
- const socialItems = computed<ILinkItem[]>(() => {
280
- return Object.entries(props.card?.social || {}).map(([key, value]: [string, string]) => ({
281
- name: value,
282
- title: key,
283
- icon: key,
284
- }));
285
- });
286
147
  </script>
287
148
 
288
149
  <style lang="scss">
289
- .info-main.--model {
290
- border: 1px solid $info-border;
291
- border-radius: $radius-xl;
292
- background-color: $info-bg;
293
- }
294
-
295
150
  .info-main__content {
296
151
  display: grid;
297
152
  column-gap: 16px;
@@ -299,16 +154,6 @@ const socialItems = computed<ILinkItem[]>(() => {
299
154
  align-items: start;
300
155
  }
301
156
 
302
- .info-main.--model .info-main__content {
303
- padding: 16px;
304
- grid-template-columns: auto 1fr;
305
-
306
- @include from-br(sm) {
307
- padding: 20px;
308
- }
309
-
310
- }
311
-
312
157
  .info-main__content.--img {
313
158
  grid-template-columns: auto 1fr;
314
159
  }
@@ -330,15 +175,10 @@ const socialItems = computed<ILinkItem[]>(() => {
330
175
 
331
176
  width: var(--size);
332
177
  height: var(--size);
333
- grid-row: 1/6;
178
+ grid-row: 1/3;
334
179
  }
335
180
  }
336
181
 
337
- .info-main__wrapper {
338
- display: grid;
339
- align-content: start;
340
- }
341
-
342
182
  .info-main__header-wrapper {
343
183
  @include to-br(sm) {
344
184
  display: flex;
@@ -385,11 +225,6 @@ const socialItems = computed<ILinkItem[]>(() => {
385
225
  flex-shrink: 0;
386
226
  }
387
227
 
388
- .info-main__social {
389
- margin-top: 8px;
390
- grid-column: 2;
391
- }
392
-
393
228
  .info-main__description {
394
229
  @include font-sm;
395
230
 
@@ -402,95 +237,6 @@ const socialItems = computed<ILinkItem[]>(() => {
402
237
  }
403
238
  }
404
239
 
405
- .--avatar .info-main__description {
406
- @include from-br(sm) {
407
- grid-column: 2;
408
- }
409
- }
410
-
411
- .info-main__parameters-wrapper {
412
- padding: 20px;
413
- border-top: 1px solid $info-parameters-border;
414
-
415
- @include from-br(sm) {
416
- grid-column: 1/-1;
417
- }
418
- }
419
-
420
- .info-main__parameters-button {
421
- @include font-sm;
422
-
423
- display: flex;
424
- margin: 0 auto;
425
- align-items: center;
426
- flex-wrap: nowrap;
427
- column-gap: 4px;
428
- color: $info-parameters-text;
429
-
430
- @include hover(color) {
431
- color: $info-parameters-text--hover;
432
- }
433
- }
434
-
435
- .info-main__parameters-button-icon {
436
- transition: var(--transition) transform;
437
- }
438
-
439
- .info-main__parameters-button-icon.--more {
440
- transform: rotate(180deg);
441
- }
442
-
443
- .info-main__also {
444
- @include font-sm;
445
-
446
- color: $info-parameters-also;
447
- margin-top: 16px;
448
-
449
- @include to-br(sm) {
450
- grid-column: 1/-1;
451
- }
452
- }
453
-
454
- .info-main__also-title {
455
- color: $info-parameters-also-title;
456
- }
457
-
458
- .info-main__born {
459
- @include font-sm;
460
-
461
- color: $info-parameters-born;
462
- margin-top: 8px;
463
-
464
- @include to-br(sm) {
465
- grid-column: 1/-1;
466
- }
467
- }
468
-
469
- .info-main__born-title {
470
- display: flex;
471
- align-items: center;
472
- flex-wrap: nowrap;
473
- column-gap: 6px;
474
- }
475
-
476
- .info-main__born-title-icon {
477
- display: inline-block;
478
- margin-right: 6px;
479
- color: $info-parameters-born-icon;
480
- vertical-align: middle;
481
- }
482
-
483
- .info-main__born-text {
484
- display: inline;
485
- vertical-align: middle;
486
- }
487
-
488
- .info-main__born-flag {
489
- display: inline;
490
- margin-left: 6px;
491
- vertical-align: middle;
492
- }
493
-
494
240
  .info-main__header-button {
495
241
  @include button-variant(info-button);
496
242
  }
@@ -0,0 +1,478 @@
1
+ <!-- Инфо-карточка модели: портрет 3:4, псевдонимы, соцсети, шапка с действиями, рождение, описание, параметры с «Show more» -->
2
+ <template>
3
+ <section class="info-model">
4
+ <div class="info-model__content">
5
+ <div class="info-model__aside">
6
+ <UiImg
7
+ :src="currentImage"
8
+ :avif-src="currentAvifSrc"
9
+ :alt="title"
10
+ :sizes="IMG_SIZES"
11
+ class="info-model__img"
12
+ width="240"
13
+ height="320"
14
+ priority
15
+ @error="imageError = true"
16
+ />
17
+ <p
18
+ v-if="card.akaNames"
19
+ class="info-model__also"
20
+ >
21
+ <span class="info-model__also-title">{{ t('also_known_as') }}&nbsp;</span>
22
+ <span class="info-model__also-text">{{ card.akaNames }}</span>
23
+ </p>
24
+ <InfoSocials
25
+ v-if="socialItems.length > 0"
26
+ :items="socialItems"
27
+ :known-icons="KNOWN_ICONS"
28
+ class="info-model__social"
29
+ icon-size="16"
30
+ />
31
+ </div>
32
+
33
+ <header class="info-model__header">
34
+ <h1 class="info-model__title">{{ title }}</h1>
35
+ <div class="info-model__actions">
36
+ <UiCount
37
+ class="info-model__count"
38
+ icon="video-camera"
39
+ >{{ $n(videosCount || card.videosCount, 'compact') }}</UiCount>
40
+ <UiBtn
41
+ :aria-label="t('share')"
42
+ :title="t('share')"
43
+ theme="muted"
44
+ size="s"
45
+ class="info-model__share"
46
+ @click="isShareOpen = true"
47
+ >
48
+ <UiIcon
49
+ name="share"
50
+ size="16"
51
+ />
52
+ <span class="info-model__share-text">{{ t('share') }}</span>
53
+ </UiBtn>
54
+ <UiFollowBtn
55
+ v-if="featureFlags.FeatureFollowEnabled"
56
+ :following="isFollowing"
57
+ :name="title"
58
+ class="info-model__follow"
59
+ size="s"
60
+ @toggle="onFollowClick"
61
+ />
62
+ </div>
63
+ </header>
64
+
65
+ <p
66
+ v-if="card.birthSummary"
67
+ class="info-model__born"
68
+ >
69
+ <UiIcon
70
+ class="info-model__born-icon"
71
+ name="geo"
72
+ size="14"
73
+ />
74
+ <span class="info-model__born-text">{{ card.birthSummary }}</span>
75
+ <!-- Национальность нигде больше не подписана — флаг единственный её носитель,
76
+ поэтому в `alt` идёт название страны, а не ISO-код. -->
77
+ <img
78
+ v-for="(item, index) in card.nationalityIsoCodes"
79
+ :key="`nationalityIsoCodes-${index}`"
80
+ :src="`/icons/flags/${item}.svg`"
81
+ :alt="isoCodeToCountryName(item, locale)"
82
+ class="info-model__born-flag"
83
+ width="20"
84
+ height="14"
85
+ >
86
+ </p>
87
+
88
+ <p
89
+ v-if="card.description"
90
+ :class="{'_truncate': !isExpanded}"
91
+ class="info-model__description"
92
+ >{{ card.description }}</p>
93
+
94
+ <InfoGrid
95
+ v-if="mainGroups.length > 0"
96
+ :groups="mainGroups"
97
+ :iso-codes="card.nationalityIsoCodes"
98
+ class="info-model__parameters"
99
+ />
100
+
101
+ <InfoGrid
102
+ v-if="extraGroups.length > 0"
103
+ :class="{'--collapsed': !isExpanded}"
104
+ :groups="extraGroups"
105
+ :iso-codes="card.nationalityIsoCodes"
106
+ class="info-model__parameters-extra"
107
+ />
108
+ </div>
109
+
110
+ <footer
111
+ v-if="hasMore"
112
+ class="info-model__footer"
113
+ >
114
+ <button
115
+ :aria-expanded="isExpanded"
116
+ class="info-model__more"
117
+ type="button"
118
+ @click="isExpanded = !isExpanded"
119
+ >
120
+ {{ isExpanded ? t('show_less') : t('show_more') }}
121
+ <UiIcon
122
+ :class="{'--expanded': isExpanded}"
123
+ class="info-model__more-icon"
124
+ name="angle-down"
125
+ size="12"
126
+ />
127
+ </button>
128
+ </footer>
129
+
130
+ <ShareMain v-model="isShareOpen">
131
+ <template #card>
132
+ <CardModelMini :card="shareModelCard"/>
133
+ </template>
134
+ </ShareMain>
135
+ </section>
136
+ </template>
137
+
138
+ <script setup lang="ts">
139
+ import { AuthSteps } from '../../runtime';
140
+ import type { ICardInfo, IGroupedParameter, ILinkItem, IModelCard } from '../../types';
141
+ import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
142
+ import { AuthorizationApiService } from '../../services/api/authorization.service';
143
+ import { groupParametersByGroup } from '../../utils/info-main/group-parameters-by-group';
144
+ import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
145
+
146
+ const props = defineProps<{
147
+ card: ICardInfo
148
+ title?: string
149
+ videosCount?: number
150
+ }>();
151
+
152
+ const { t, locale } = useI18n();
153
+
154
+ const featureFlags = useRuntimeConfig().public.featureFlags;
155
+
156
+ const KNOWN_ICONS = ['facebook', 'instagram', 'tiktok', 'twitter', 'x', 'youtube', 'snapchat'];
157
+
158
+ const PLACEHOLDER = '/img/model-placeholder-portrait.webp';
159
+ const PLACEHOLDER_AVIF = '/img/model-placeholder-portrait.avif';
160
+
161
+ /** `.info-model__img` — 160px до sm, 240px дальше. `auto` впереди — см. `UiImg`. */
162
+ const IMG_SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 160px, 240px`;
163
+
164
+ const title = computed(() => props.title || props.card.title);
165
+
166
+ const imageError = ref(false);
167
+ const isFallback = computed(() => imageError.value || !props.card.portraitImg);
168
+ const currentImage = computed(() => (isFallback.value ? PLACEHOLDER : props.card.portraitImg));
169
+ const currentAvifSrc = computed(() => (isFallback.value ? PLACEHOLDER_AVIF : props.card.portraitAvifUrl));
170
+
171
+ const socialItems = computed<ILinkItem[]>(() => (
172
+ Object.entries(props.card.social || {}).map(([key, value]) => ({
173
+ name: value || '',
174
+ title: key,
175
+ icon: key,
176
+ }))
177
+ ));
178
+
179
+ const groupedParameters = computed<IGroupedParameter[]>(() =>
180
+ groupParametersByGroup(props.card.parameters ?? []),
181
+ );
182
+
183
+ /** Первая группа видна всегда; вторая свёрнутой видна только с sm, остальные — после «Show more». */
184
+ const mainGroups = computed(() => groupedParameters.value.slice(0, 1));
185
+
186
+ const isExpanded = ref(false);
187
+
188
+ const extraGroups = computed(() => (
189
+ isExpanded.value ? groupedParameters.value.slice(1) : groupedParameters.value.slice(1, 2)
190
+ ));
191
+
192
+ const hasMore = computed(() => groupedParameters.value.length > 1 || Boolean(props.card.description));
193
+
194
+ const isShareOpen = ref(false);
195
+
196
+ const { isAuthorized } = useUser(AuthorizationApiService);
197
+ const { openAuthPopup } = useAuthPopup();
198
+
199
+ /** Заглушка вместо ручки подписки: состояние живёт только в компоненте, на бэк ничего не уходит */
200
+ const isFollowing = ref(false);
201
+
202
+ function onFollowClick() {
203
+ if (isAuthorized.value) {
204
+ isFollowing.value = !isFollowing.value;
205
+ } else {
206
+ openAuthPopup(AuthSteps.Login, 'register_first');
207
+ }
208
+ }
209
+
210
+ function getParameter(name: string) {
211
+ return props.card.parameters?.find(p => p.name === name)?.values?.[ 0 ]?.name;
212
+ }
213
+
214
+ /**
215
+ * `IModelCard.country` — пара «код файла флага + читаемое имя», как её отдаёт
216
+ * cleanModelCard. Берём первый код: у модели с двумя национальностями целый массив дал бы
217
+ * путь `/icons/flags/us,gb.svg`, а пустой массив — битую картинку.
218
+ */
219
+ const shareCountry = computed(() => {
220
+ const iso = props.card.nationalityIsoCodes?.[ 0 ] || '';
221
+ if (!iso) return undefined;
222
+
223
+ return { name: iso, title: isoCodeToCountryName(iso, locale.value) };
224
+ });
225
+
226
+ const shareModelCard = computed<IModelCard>(() => ({
227
+ title: props.card.title,
228
+ videosCount: props.card.videosCount,
229
+ thumbUrl: props.card.img || '',
230
+ guid: props.card.guid || '',
231
+ parameters: [],
232
+ country: shareCountry.value,
233
+ age: getParameter('age'),
234
+ }));
235
+ </script>
236
+
237
+ <style lang="scss">
238
+ .info-model {
239
+ border: 1px solid $info-model-border;
240
+ border-radius: $radius-xl;
241
+ background-color: $info-model-bg;
242
+ }
243
+
244
+ .info-model__content {
245
+ display: grid;
246
+ grid-template-columns: 160px minmax(0, 1fr);
247
+ grid-template-rows: auto 1fr;
248
+ grid-template-areas:
249
+ 'aside header'
250
+ 'aside parameters'
251
+ 'born born'
252
+ 'description description'
253
+ 'extra extra';
254
+ column-gap: 16px;
255
+ padding: 16px;
256
+
257
+ @include from-br(sm) {
258
+ grid-template-columns: 240px minmax(0, 1fr);
259
+ grid-template-rows: auto auto auto auto 1fr;
260
+ grid-template-areas:
261
+ 'aside header'
262
+ 'aside born'
263
+ 'aside description'
264
+ 'aside parameters'
265
+ 'aside extra';
266
+ padding: 20px;
267
+ }
268
+ }
269
+
270
+ .info-model__aside {
271
+ grid-area: aside;
272
+ align-self: start;
273
+ }
274
+
275
+ .info-model__img {
276
+ display: block;
277
+ width: 100%;
278
+ height: auto;
279
+ aspect-ratio: 3/4;
280
+ border-radius: $radius-lg;
281
+ object-fit: cover;
282
+ background-color: $info-model-img-bg;
283
+ }
284
+
285
+ .info-model__also {
286
+ @include font-xs;
287
+
288
+ margin-top: 8px;
289
+ color: $info-model-also;
290
+ line-height: 1.625;
291
+
292
+ @include from-br(sm) {
293
+ @include font-sm;
294
+
295
+ line-height: 1.625;
296
+ }
297
+ }
298
+
299
+ .info-model__also-title {
300
+ color: $info-model-also-title;
301
+ }
302
+
303
+ .info-model__social {
304
+ margin-top: 8px;
305
+
306
+ @include from-br(sm) {
307
+ margin-top: 12px;
308
+ }
309
+ }
310
+
311
+ .info-model__header {
312
+ display: grid;
313
+ grid-area: header;
314
+ row-gap: 8px;
315
+ margin-bottom: 8px;
316
+
317
+ @include from-br(sm) {
318
+ display: flex;
319
+ flex-wrap: wrap;
320
+ align-items: center;
321
+ column-gap: 12px;
322
+ }
323
+ }
324
+
325
+ .info-model__title {
326
+ @include font-base;
327
+
328
+ min-width: 0;
329
+ font-weight: 700;
330
+ line-height: 1.25;
331
+ text-transform: uppercase;
332
+ overflow-wrap: break-word;
333
+
334
+ @include from-br(sm) {
335
+ @include font-lg;
336
+ }
337
+ }
338
+
339
+ .info-model__actions {
340
+ display: flex;
341
+ align-items: center;
342
+ gap: 8px;
343
+
344
+ @include from-br(sm) {
345
+ display: contents;
346
+ }
347
+ }
348
+
349
+ .info-model__count {
350
+ padding: 7px 12px;
351
+ flex-shrink: 0;
352
+ }
353
+
354
+ .info-model__share {
355
+ @include button-variant(info-model-button);
356
+
357
+ @include to-br(sm) {
358
+ width: 36px;
359
+ padding: 0;
360
+ }
361
+ }
362
+
363
+ .info-model__share-text {
364
+ @include to-br(sm) {
365
+ display: none;
366
+ }
367
+ }
368
+
369
+ .info-model__follow {
370
+ margin-inline-start: auto;
371
+ }
372
+
373
+ .info-model__born {
374
+ @include font-sm;
375
+
376
+ grid-area: born;
377
+ margin-top: 20px;
378
+ color: $info-model-born;
379
+
380
+ @include from-br(sm) {
381
+ margin-top: 0;
382
+ margin-bottom: 12px;
383
+ }
384
+ }
385
+
386
+ .info-model__born-icon {
387
+ display: inline-block;
388
+ margin-right: 6px;
389
+ color: $info-model-born-icon;
390
+ vertical-align: middle;
391
+ }
392
+
393
+ .info-model__born-text {
394
+ vertical-align: middle;
395
+ }
396
+
397
+ .info-model__born-flag {
398
+ display: inline;
399
+ margin-left: 6px;
400
+ vertical-align: middle;
401
+ }
402
+
403
+ .info-model__description {
404
+ @include font-sm;
405
+
406
+ --line-clamp: 2;
407
+
408
+ grid-area: description;
409
+ margin-top: 16px;
410
+ line-height: 1.625;
411
+ color: $info-model-description;
412
+
413
+ @include from-br(sm) {
414
+ margin-top: 0;
415
+ }
416
+ }
417
+
418
+ .info-model__parameters {
419
+ grid-area: parameters;
420
+ align-self: start;
421
+ margin-top: 16px;
422
+
423
+ @include from-br(sm) {
424
+ margin-top: 20px;
425
+ }
426
+ }
427
+
428
+ .info-model__parameters .info-grid__item-title {
429
+ @include to-br(sm) {
430
+ display: none;
431
+ }
432
+ }
433
+
434
+ .info-model__parameters .info-grid__item:not(.--link) .info-grid__item-value {
435
+ @include to-br(sm) {
436
+ border-left: none;
437
+ }
438
+ }
439
+
440
+ .info-model__parameters-extra {
441
+ grid-area: extra;
442
+ margin-top: 20px;
443
+ }
444
+
445
+ .info-model__parameters-extra.--collapsed {
446
+ @include to-br(sm) {
447
+ display: none;
448
+ }
449
+ }
450
+
451
+ .info-model__footer {
452
+ display: flex;
453
+ justify-content: center;
454
+ padding: 12px 20px;
455
+ border-top: 1px solid $info-model-footer-border;
456
+ }
457
+
458
+ .info-model__more {
459
+ @include font-sm;
460
+
461
+ display: flex;
462
+ align-items: center;
463
+ column-gap: 6px;
464
+ color: $info-model-more-text;
465
+
466
+ @include hover(color) {
467
+ color: $info-model-more-text--hover;
468
+ }
469
+ }
470
+
471
+ .info-model__more-icon {
472
+ transition: var(--transition) transform;
473
+ }
474
+
475
+ .info-model__more-icon.--expanded {
476
+ transform: rotate(180deg);
477
+ }
478
+ </style>
@@ -1,14 +1,13 @@
1
1
  <!-- Страница видео модели: инфо, теги, грид видео, пагинация, похожие -->
2
2
  <template>
3
3
  <div class="view-model-videos _page _container">
4
- <InfoMain
4
+ <InfoModel
5
5
  v-if="cardInfo && isFirstPage && !isFilteredPage"
6
6
  :key="`info-${cardInfo.title}`"
7
7
  :card="cardInfo"
8
8
  :title="title"
9
9
  :videos-count="infoVideosCount"
10
10
  class="view-model-videos__info"
11
- type="model"
12
11
  />
13
12
 
14
13
  <UiSectionTitle
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.994",
4
+ "version": "0.0.995",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -9,7 +9,12 @@ 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
+ portraits: {
13
+ dark: {
14
+ square: { width: 256, height: 256, webp: 'https://cdn/sq.webp', avif: 'https://cdn/sq.avif' },
15
+ tall: { width: 480, height: 640, webp: 'https://cdn/tall.webp', avif: 'https://cdn/tall.avif' },
16
+ },
17
+ },
13
18
  parameters: 'height: 170',
14
19
  long_description: 'long desc',
15
20
  bio: 'bio text',
@@ -31,6 +36,8 @@ describe('cleanModelInfo', () => {
31
36
  expect(result.videosCount).toBe(50);
32
37
  expect(result.img).toBe('https://cdn/sq.webp');
33
38
  expect(result.imgAvifUrl).toBe('https://cdn/sq.avif');
39
+ expect(result.portraitImg).toBe('https://cdn/tall.webp');
40
+ expect(result.portraitAvifUrl).toBe('https://cdn/tall.avif');
34
41
  expect(result.parameters).toBe('height: 170');
35
42
  expect(result.fullDescription).toBe('long desc');
36
43
  expect(result.relatedNames).toEqual(['Model B']);
@@ -52,6 +59,7 @@ describe('cleanModelInfo', () => {
52
59
  expect(result.social).toBeUndefined();
53
60
  expect(result.videosCount).toBe(0);
54
61
  expect(result.img).toBe('');
62
+ expect(result.portraitImg).toBe('');
55
63
  expect(result.parameters).toEqual([]);
56
64
  expect(result.fullDescription).toBe('');
57
65
  expect(result.relatedNames).toEqual([]);
@@ -3,7 +3,7 @@ import { pickPortraitVariant } from '../pick-portrait-variant';
3
3
 
4
4
  /** Нормализует сырые данные страницы модели к типу ICardInfo. */
5
5
  export const cleanModelInfo = (card: IRawModelInfo, theme?: string): ICardInfo => {
6
- // В шапке страницы аватар круглый, поэтому берём квадратный кроп.
6
+ // `tall` — в шапку страницы, `square` — в мини-карточку шаринга и круглые аватары.
7
7
  const portrait = pickPortraitVariant(card.portraits, theme);
8
8
 
9
9
  return {
@@ -13,6 +13,8 @@ export const cleanModelInfo = (card: IRawModelInfo, theme?: string): ICardInfo =
13
13
  videosCount: card.videos_count || 0,
14
14
  img: portrait?.square?.webp || '',
15
15
  imgAvifUrl: portrait?.square?.avif,
16
+ portraitImg: portrait?.tall?.webp || '',
17
+ portraitAvifUrl: portrait?.tall?.avif,
16
18
  parameters: card.parameters || [],
17
19
  fullDescription: card.long_description || '',
18
20
  relatedNames: card.relatedNames || [],
@@ -24,6 +24,9 @@ export interface ICardInfo {
24
24
  social?: {
25
25
  [key: string]: string | undefined;
26
26
  };
27
+ /** Портретный кроп 3:4 для шапки страницы модели; `img` остаётся квадратным. */
28
+ portraitImg?: string;
29
+ portraitAvifUrl?: string;
27
30
  parameters?: IParameterModel[]
28
31
  relatedNames?: string[]
29
32
  akaNames: string;