itube-specs 0.0.1020 → 0.0.1023

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/SITE_UPDATES.md CHANGED
@@ -11,6 +11,33 @@
11
11
  коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
12
12
  кому именно (всем / сайтам с N), что будет, если не сделать.
13
13
 
14
+ ## 0.0.1023 — 2026-10-08
15
+
16
+ - **Всем сайтам:** в `package.json` заменить скрипт `spec` на `"spec": "sh node_modules/itube-specs/scripts/spec.sh"`
17
+ (вся логика теперь в слое: ждёт свежую версию в реестре, пишет `SITE_TODO.md`, обновляет каталог). Делать
18
+ после установки этой версии — в старых `spec.sh` нет. Не заменить — дальнейшие правки `spec` в слое до сайта
19
+ не дойдут, а старый `spec` сразу после публикации может поставить прошлую версию.
20
+
21
+ ## 0.0.1022 — 2026-10-08
22
+
23
+ - **Всем сайтам со скриптом `spec`:** заменить его в `package.json` на версию из `NEW_SITE_CHECKLIST.md`
24
+ (запоминает версию слоя до установки, передаёт её в `site-updates.mjs`, ставит с `--prefer-online`). Образец —
25
+ коммит gay-cool-new с этим изменением. Не заменить — повторный `npm run spec` до коммита снова пишет в
26
+ `SITE_TODO.md` уже сделанные пункты, а `spec` сразу после публикации может поставить прошлую версию из кэша
27
+ npm. hd-sex-new: там `spec` — голый `npm install itube-specs@latest`, заменить целиком.
28
+
29
+ ## 0.0.1021 — 2026-10-08
30
+
31
+ - **Сайтам со своей темой** (`assets/scss/vars/_theme.scss` в сайте, как у hdsex-remaster): завести токены
32
+ нового инфо-блока модели — как в `themes/dark/_theme.scss` слоя: `$info-model-about-title`,
33
+ `$info-model-about-text`, `$info-model-summary-bg`, `$info-model-summary-bg--hover`, `$info-model-summary-text`,
34
+ `$info-model-summary-icon`, `$info-grid-item-value`. Удалены (можно убрать): `$info-model-border`,
35
+ `$info-model-bg`, `$info-model-footer-border`. По макету: `$info-model-description` → `$color-zinc-300`,
36
+ `$info-grid-item-title` → `$color-zinc-450`. Не завести — сборка падает на undefined variable.
37
+ - **Сайтам с форком `components/view/model-videos.vue`:** убрать `<LazyUiFullDescr>` с `fullDescription` внизу
38
+ страницы — длинное описание теперь показывает `InfoModel` в блоке «About». Не убрать — текст на странице
39
+ задублируется.
40
+
14
41
  ## 0.0.1019 — 2026-10-08
15
42
 
16
43
  - **Сайтам со своей темой** (`assets/scss/vars/_theme.scss` в сайте, как у hdsex-remaster): завести токены
@@ -4,8 +4,7 @@
4
4
  box-sizing: border-box;
5
5
  }
6
6
 
7
- ul[class],
8
- ol[class] {
7
+ :where(ul[class], ol[class]) {
9
8
  padding: 0;
10
9
  }
11
10
 
@@ -15,8 +14,7 @@ h2,
15
14
  h3,
16
15
  h4,
17
16
  p,
18
- ul[class],
19
- ol[class],
17
+ :where(ul[class], ol[class]),
20
18
  li,
21
19
  figure,
22
20
  figcaption,
@@ -33,8 +31,7 @@ body {
33
31
  line-height: 1.5;
34
32
  }
35
33
 
36
- ul[class],
37
- ol[class] {
34
+ :where(ul[class], ol[class]) {
38
35
  list-style: none;
39
36
  }
40
37
 
@@ -814,8 +814,9 @@ $filters-main-bg--hover: $color-zinc-700-70;
814
814
  $info-grid-group-title: $color-zinc-300;
815
815
  $info-grid-item-border: $color-zinc-700-50;
816
816
  $info-grid-item-bg--hover: $color-zinc-700-30;
817
- $info-grid-item-title: $color-zinc-500;
817
+ $info-grid-item-title: $color-zinc-450;
818
818
  $info-grid-item-title-bg: $color-zinc-900;
819
+ $info-grid-item-value: $color-zinc-300;
819
820
  $info-grid-item-value-bg: $color-zinc-900;
820
821
  $info-grid-item-value-bg--link: $color-zinc-700-50;
821
822
  $info-grid-item-value-link-bg--hover: $color-zinc-700-50;
@@ -832,11 +833,14 @@ $info-img-border: $color-zinc-700;
832
833
  $info-description: $color-zinc-400;
833
834
 
834
835
  // ─── Info model ───
835
- $info-model-border: $color-zinc-800-50;
836
- $info-model-bg: $color-zinc-900-50;
837
836
  $info-model-img-bg: $color-zinc-800;
838
- $info-model-description: $color-zinc-400;
839
- $info-model-footer-border: $color-zinc-800-50;
837
+ $info-model-description: $color-zinc-300;
838
+ $info-model-about-title: $color-zinc-300;
839
+ $info-model-about-text: $color-zinc-400;
840
+ $info-model-summary-bg: $color-zinc-700-50;
841
+ $info-model-summary-bg--hover: $color-zinc-700;
842
+ $info-model-summary-text: $color-white;
843
+ $info-model-summary-icon: $color-zinc-400;
840
844
  $info-model-more-text: $color-zinc-500;
841
845
  $info-model-more-text--hover: $color-zinc-300;
842
846
  $info-model-also: $color-zinc-400;
@@ -843,6 +843,7 @@ $info-grid-item-border: $color-gray-300;
843
843
  $info-grid-item-bg--hover: $color-slate-700;
844
844
  $info-grid-item-title: $color-gray-500;
845
845
  $info-grid-item-title-bg: $color-white;
846
+ $info-grid-item-value: $color-gray-900;
846
847
  $info-grid-item-value-bg: $color-gray-100;
847
848
  $info-grid-item-value-bg--link: $color-slate-700;
848
849
  $info-grid-item-value-link-bg--hover: $color-slate-750;
@@ -859,11 +860,14 @@ $info-img-border: transparent;
859
860
  $info-description: $color-gray-600;
860
861
 
861
862
  // ─── Info model ───
862
- $info-model-border: transparent;
863
- $info-model-bg: $color-gray-200;
864
863
  $info-model-img-bg: $color-gray-300;
865
864
  $info-model-description: $color-gray-600;
866
- $info-model-footer-border: $color-gray-300;
865
+ $info-model-about-title: $color-gray-600;
866
+ $info-model-about-text: $color-gray-600;
867
+ $info-model-summary-bg: $color-slate-700;
868
+ $info-model-summary-bg--hover: $color-slate-750;
869
+ $info-model-summary-text: $color-white;
870
+ $info-model-summary-icon: $color-gray-400;
867
871
  $info-model-more-text: $color-gray-600;
868
872
  $info-model-more-text--hover: $color-gray-600;
869
873
  $info-model-also: $color-gray-600;
@@ -677,9 +677,7 @@ onBeforeUnmount(() => {
677
677
  }
678
678
  }
679
679
 
680
- // селектор с родителем не случайно: ресет обнуляет отступы у `ul[class]`, и
681
- // одиночного класса ему не хватает
682
- .card-webcam > .card-webcam__tags {
680
+ .card-webcam__tags {
683
681
  display: flex;
684
682
  flex-wrap: wrap;
685
683
  gap: 6px;
@@ -688,7 +686,7 @@ onBeforeUnmount(() => {
688
686
 
689
687
  // на узких экранах теги в один ряд с горизонтальным скроллом: в две колонки они
690
688
  // не влезают, а перенос делал карточки в сетке разной высоты
691
- .card-webcam > .card-webcam__tags {
689
+ .card-webcam__tags {
692
690
  @include to-br(xm) {
693
691
  flex-wrap: nowrap;
694
692
  overflow-x: auto;
@@ -26,7 +26,7 @@
26
26
  :is="item.isFilter && value ? NuxtLink : 'div'"
27
27
  :key="`s-info-grid-value-${gIndex}-${index}-${vIndex}`"
28
28
  :class="{'--link': item.isFilter && value}"
29
- :to="item.isFilter && value ? generateLink(link(item, value.name)) : undefined"
29
+ :to="item.isFilter && value ? generateLink(modelFilterLink(item, value.name)) : undefined"
30
30
  class="info-grid__item"
31
31
  >
32
32
  <span
@@ -67,7 +67,8 @@
67
67
  </template>
68
68
 
69
69
  <script setup lang="ts">
70
- import type { IGroupedParameter, IGroupedParameterItem } from '../../types';
70
+ import type { IGroupedParameter } from '../../types';
71
+ import { modelFilterLink } from '../../utils/info-grid/model-filter-link';
71
72
 
72
73
  defineProps<{
73
74
  groups: IGroupedParameter[]
@@ -84,16 +85,6 @@ const NuxtLink = resolveComponent('NuxtLink');
84
85
  const { hiddenByUnits } = useUnits();
85
86
  const { generateLink } = useGenerateLink();
86
87
 
87
- function link(item: IGroupedParameterItem, value: string) {
88
- const formattedValue = value.toLowerCase().replace(/\s+/g, '+');
89
-
90
- if (item.kind === 'range') {
91
- return `/models?filter_${item.name}_from=${formattedValue}&filter_${item.name}_to=${formattedValue}`;
92
- } else {
93
- return `/models?filter_${item.name}=${formattedValue}`;
94
- }
95
- }
96
-
97
88
  function isSuccess(value: string) {
98
89
  const values = ['active'];
99
90
  return values.includes(value);
@@ -108,7 +99,7 @@ function isWarning(value: string) {
108
99
  <style lang="scss">
109
100
  .info-grid {
110
101
  display: grid;
111
- row-gap: 28px;
102
+ row-gap: 20px;
112
103
  }
113
104
 
114
105
  .info-grid__group-title {
@@ -116,9 +107,9 @@ function isWarning(value: string) {
116
107
 
117
108
  margin-bottom: 12px;
118
109
  color: $info-grid-group-title;
119
- line-height: 16px;
110
+ font-weight: 600;
111
+ letter-spacing: var(--tracking-wide);
120
112
  text-transform: uppercase;
121
- font-weight: 500;
122
113
  }
123
114
 
124
115
  .info-grid__group-items {
@@ -133,14 +124,16 @@ function isWarning(value: string) {
133
124
 
134
125
  display: flex;
135
126
  flex-wrap: nowrap;
136
- align-items: center;
127
+ height: 36px;
137
128
  border-radius: $radius-lg;
138
129
  border: 1px solid $info-grid-item-border;
139
130
  overflow: hidden;
140
131
  }
141
132
 
142
133
  .info-grid__item-title {
143
- padding: 6px 10px;
134
+ display: flex;
135
+ align-items: center;
136
+ padding-inline: 10px;
144
137
  color: $info-grid-item-title;
145
138
  background-color: $info-grid-item-title-bg;
146
139
  }
@@ -150,7 +143,7 @@ function isWarning(value: string) {
150
143
  flex-wrap: nowrap;
151
144
  align-items: center;
152
145
  column-gap: 4px;
153
- padding: 6px 10px;
146
+ padding-inline: 10px;
154
147
  }
155
148
 
156
149
  .info-grid__item.--link {
@@ -175,10 +168,14 @@ function isWarning(value: string) {
175
168
  }
176
169
 
177
170
  .info-grid__item:not(.--link) .info-grid__item-value {
178
- border-left: 1px solid $info-grid-item-value-border;
171
+ color: $info-grid-item-value;
179
172
  background-color: $info-grid-item-value-bg;
180
173
  }
181
174
 
175
+ .info-grid__item:not(.--link) .info-grid__item-title + .info-grid__item-value {
176
+ border-left: 1px solid $info-grid-item-value-border;
177
+ }
178
+
182
179
  .info-grid__item:not(.--link) .info-grid__item-title {
183
180
  background-color: $info-grid-item-value-bg;
184
181
  }
@@ -203,6 +200,9 @@ function isWarning(value: string) {
203
200
  .info-grid__plain-text {
204
201
  @include font-sm;
205
202
 
203
+ max-width: 740px;
204
+ margin-block: -6px;
206
205
  color: $info-grid-plain-text;
206
+ line-height: 1.625;
207
207
  }
208
208
  </style>
@@ -1,131 +1,164 @@
1
- <!-- Инфо-карточка модели: портрет 3:4, псевдонимы, соцсети, шапка с действиями, рождение, описание, параметры с «Show more» -->
1
+ <!-- Инфо-блок модели: портрет 3:4, псевдонимы, соцсети, шапка с действиями, рождение, описание, сводка-чипсы; «About» и группы параметров — по «Show more» -->
2
2
  <template>
3
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="card.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"
4
+ <div class="info-model__aside">
5
+ <UiImg
6
+ :src="currentImage"
7
+ :avif-src="currentAvifSrc"
8
+ :alt="card.title"
9
+ :sizes="IMG_SIZES"
10
+ class="info-model__img"
11
+ width="240"
12
+ height="320"
13
+ priority
14
+ @error="imageError = true"
15
+ />
16
+ <p
17
+ v-if="akaNames.length"
18
+ class="info-model__also"
19
+ >
20
+ <span class="info-model__also-title">{{ t('also_known_as') }}</span>
21
+ <template
22
+ v-for="name in akaNames"
23
+ :key="name"
20
24
  >
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>
25
+ {{ ' ' }}<span class="info-model__also-name">{{ name }}</span>
26
+ </template>
27
+ </p>
28
+ <InfoSocials
29
+ v-if="socialItems.length"
30
+ :items="socialItems"
31
+ :known-icons="KNOWN_ICONS"
32
+ class="info-model__social"
33
+ icon-size="20"
34
+ />
35
+ </div>
32
36
 
33
- <header class="info-model__header">
37
+ <header class="info-model__header">
38
+ <div class="info-model__heading">
34
39
  <h1 class="info-model__title">{{ card.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="card.title"
58
- class="info-model__follow"
59
- size="s"
60
- @toggle="onFollowClick"
40
+ <UiCount
41
+ class="info-model__count"
42
+ icon="video-camera"
43
+ >{{ $n(videosCount || card.videosCount, 'compact') }}</UiCount>
44
+ <UiBtn
45
+ :aria-label="t('share')"
46
+ :title="t('share')"
47
+ theme="muted"
48
+ size="s"
49
+ class="info-model__share"
50
+ @click="isShareOpen = true"
51
+ >
52
+ <UiIcon
53
+ name="share"
54
+ size="16"
61
55
  />
62
- </div>
63
- </header>
56
+ <span class="info-model__share-text">{{ t('share') }}</span>
57
+ </UiBtn>
58
+ </div>
59
+ <UiFollowBtn
60
+ v-if="featureFlags.FeatureFollowEnabled"
61
+ :following="isFollowing"
62
+ :name="card.title"
63
+ class="info-model__follow"
64
+ size="s"
65
+ @toggle="onFollowClick"
66
+ />
67
+ </header>
64
68
 
65
- <p
66
- v-if="card.birthSummary"
67
- class="info-model__born"
69
+ <p
70
+ v-if="card.birthSummary"
71
+ class="info-model__born"
72
+ >
73
+ <UiIcon
74
+ class="info-model__born-icon"
75
+ name="geo"
76
+ size="14"
77
+ />
78
+ <span class="info-model__born-text">{{ card.birthSummary }}</span>
79
+ <!-- Национальность нигде больше не подписана — флаг единственный её носитель,
80
+ поэтому в `alt` идёт название страны, а не ISO-код. -->
81
+ <img
82
+ v-for="(item, index) in card.nationalityIsoCodes"
83
+ :key="`nationalityIsoCodes-${index}`"
84
+ :src="`/icons/flags/${item}.svg`"
85
+ :alt="isoCodeToCountryName(item, locale)"
86
+ class="info-model__born-flag"
87
+ width="20"
88
+ height="14"
68
89
  >
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"
90
+ </p>
91
+
92
+ <p
93
+ v-if="card.description"
94
+ class="info-model__description"
95
+ >{{ card.description }}</p>
96
+
97
+ <ul
98
+ v-if="summaryItems.length"
99
+ class="info-model__summary"
100
+ >
101
+ <li
102
+ v-for="item in summaryItems"
103
+ :key="item.key"
104
+ >
105
+ <component
106
+ :is="item.to ? NuxtLink : 'span'"
107
+ :class="{'--link': item.to}"
108
+ :to="item.to"
109
+ class="info-model__summary-item"
85
110
  >
86
- </p>
111
+ <UiIcon
112
+ v-if="item.icon"
113
+ :name="item.icon"
114
+ class="info-model__summary-icon"
115
+ prefix="models"
116
+ size="16"
117
+ />
118
+ {{ item.title }}
119
+ </component>
120
+ </li>
121
+ </ul>
87
122
 
88
- <p
89
- v-if="card.description"
90
- :class="{'_truncate': !isExpanded}"
91
- class="info-model__description"
92
- >{{ card.description }}</p>
123
+ <div
124
+ v-if="hasMore"
125
+ :class="{'--collapsed': !isExpanded, '--about': card.fullDescription}"
126
+ class="info-model__details"
127
+ >
128
+ <div
129
+ v-if="card.fullDescription"
130
+ class="info-model__about"
131
+ >
132
+ <h3 class="info-model__about-title">{{ t('model_about') }}</h3>
133
+ <p
134
+ :class="{'_truncate': !isExpanded}"
135
+ class="info-model__about-text"
136
+ >{{ card.fullDescription }}</p>
137
+ </div>
93
138
 
94
139
  <InfoGrid
95
- v-if="mainGroups.length > 0"
96
- :groups="mainGroups"
140
+ v-if="groupedParameters.length"
141
+ :groups="groupedParameters"
97
142
  :iso-codes="card.nationalityIsoCodes"
98
143
  class="info-model__parameters"
99
144
  />
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
145
  </div>
109
146
 
110
- <footer
147
+ <button
111
148
  v-if="hasMore"
112
- class="info-model__footer"
149
+ :aria-expanded="isExpanded"
150
+ class="info-model__more"
151
+ type="button"
152
+ @click="isExpanded = !isExpanded"
113
153
  >
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>
154
+ {{ isExpanded ? t('show_less') : t('show_more') }}
155
+ <UiIcon
156
+ :class="{'--expanded': isExpanded}"
157
+ class="info-model__more-icon"
158
+ name="angle-down"
159
+ size="16"
160
+ />
161
+ </button>
129
162
 
130
163
  <ShareMain v-model="isShareOpen">
131
164
  <template #card>
@@ -141,6 +174,7 @@ import type { ICardInfo, IGroupedParameter, ILinkItem, IModelCard } from '../../
141
174
  import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
142
175
  import { AuthorizationApiService } from '../../services/api/authorization.service';
143
176
  import { groupParametersByGroup } from '../../utils/info-main/group-parameters-by-group';
177
+ import { modelFilterLink } from '../../utils/info-grid/model-filter-link';
144
178
  import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
145
179
 
146
180
  const props = defineProps<{
@@ -152,9 +186,14 @@ const { t, locale } = useI18n();
152
186
 
153
187
  const featureFlags = useRuntimeConfig().public.featureFlags;
154
188
  const placeholderOf = useModelPlaceholder();
189
+ const { generateLink } = useGenerateLink();
190
+ const NuxtLink = resolveComponent('NuxtLink');
155
191
 
156
192
  const KNOWN_ICONS = ['facebook', 'instagram', 'tiktok', 'twitter', 'x', 'youtube', 'snapchat'];
157
193
 
194
+ /** Параметры сводки под описанием, в порядке показа. В группах ниже они не повторяются. */
195
+ const SUMMARY_NAMES = ['guy_type', 'body_build', 'ethnicity', 'role', 'age'];
196
+
158
197
  const placeholder = computed(() => placeholderOf(props.card.gender, 'portrait'));
159
198
 
160
199
  /** `.info-model__img` — 160px до sm, 240px дальше. `auto` впереди — см. `UiImg`. */
@@ -165,6 +204,8 @@ const isFallback = computed(() => imageError.value || !props.card.portraitImg);
165
204
  const currentImage = computed(() => (isFallback.value ? placeholder.value.webp : props.card.portraitImg));
166
205
  const currentAvifSrc = computed(() => (isFallback.value ? placeholder.value.avif : props.card.portraitAvifUrl));
167
206
 
207
+ const akaNames = computed(() => props.card.akaNames.split(', ').filter(Boolean));
208
+
168
209
  const socialItems = computed<ILinkItem[]>(() => (
169
210
  Object.entries(props.card.social || {}).map(([key, value]) => ({
170
211
  name: value || '',
@@ -173,20 +214,27 @@ const socialItems = computed<ILinkItem[]>(() => (
173
214
  }))
174
215
  ));
175
216
 
176
- const groupedParameters = computed<IGroupedParameter[]>(() =>
177
- groupParametersByGroup(props.card.parameters ?? []),
178
- );
179
-
180
- /** Первая группа видна всегда; вторая свёрнутой видна только с sm, остальные — после «Show more». */
181
- const mainGroups = computed(() => groupedParameters.value.slice(0, 1));
217
+ const summaryItems = computed(() => SUMMARY_NAMES.flatMap((name) => {
218
+ const param = props.card.parameters?.find(p => p.name === name);
219
+ if (!param) return [];
182
220
 
183
- const isExpanded = ref(false);
221
+ return param.values.map(value => ({
222
+ key: `${name}-${value.name}`,
223
+ title: name === 'age' ? `${value.title} ${t('yo')}` : value.title,
224
+ icon: name === 'age' ? undefined : param.icon,
225
+ to: param.is_filter ? generateLink(modelFilterLink(param, value.name)) : undefined,
226
+ }));
227
+ }));
184
228
 
185
- const extraGroups = computed(() => (
186
- isExpanded.value ? groupedParameters.value.slice(1) : groupedParameters.value.slice(1, 2)
229
+ /** Страна уже есть в строке рождения флагом — отдельным чипсом её не повторяем. */
230
+ const groupedParameters = computed<IGroupedParameter[]>(() => groupParametersByGroup(
231
+ (props.card.parameters ?? []).filter(p => !SUMMARY_NAMES.includes(p.name)
232
+ && !(p.name === 'nationality_iso_codes' && props.card.birthSummary)),
187
233
  ));
188
234
 
189
- const hasMore = computed(() => groupedParameters.value.length > 1 || Boolean(props.card.description));
235
+ const isExpanded = ref(false);
236
+
237
+ const hasMore = computed(() => groupedParameters.value.length > 0 || Boolean(props.card.fullDescription));
190
238
 
191
239
  const isShareOpen = ref(false);
192
240
 
@@ -236,34 +284,30 @@ const shareModelCard = computed<IModelCard>(() => ({
236
284
 
237
285
  <style lang="scss">
238
286
  .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
287
  display: grid;
246
288
  grid-template-columns: 160px minmax(0, 1fr);
247
- grid-template-rows: auto 1fr;
289
+ grid-template-rows: auto auto auto auto auto 1fr auto;
248
290
  grid-template-areas:
249
291
  'aside header'
250
- 'aside parameters'
251
- 'born born'
252
- 'description description'
253
- 'extra extra';
292
+ 'aside born'
293
+ 'aside description'
294
+ 'aside summary'
295
+ 'aside more'
296
+ 'aside .'
297
+ 'details details';
254
298
  column-gap: 16px;
255
- padding: 16px;
256
299
 
257
300
  @include from-br(sm) {
258
301
  grid-template-columns: 240px minmax(0, 1fr);
259
- grid-template-rows: auto auto auto auto 1fr;
302
+ grid-template-rows: auto auto auto auto auto auto 1fr;
260
303
  grid-template-areas:
261
304
  'aside header'
262
305
  'aside born'
263
306
  'aside description'
264
- 'aside parameters'
265
- 'aside extra';
266
- padding: 20px;
307
+ 'aside summary'
308
+ 'aside details'
309
+ 'aside more'
310
+ 'aside .';
267
311
  }
268
312
  }
269
313
 
@@ -287,7 +331,7 @@ const shareModelCard = computed<IModelCard>(() => ({
287
331
  @include font-xs;
288
332
 
289
333
  margin-top: 8px;
290
- color: $info-model-also;
334
+ color: $info-model-also-title;
291
335
  line-height: 1.625;
292
336
 
293
337
  @include from-br(sm) {
@@ -297,8 +341,13 @@ const shareModelCard = computed<IModelCard>(() => ({
297
341
  }
298
342
  }
299
343
 
300
- .info-model__also-title {
301
- color: $info-model-also-title;
344
+ .info-model__also-name {
345
+ color: $info-model-also;
346
+ white-space: nowrap;
347
+ }
348
+
349
+ .info-model__also-name:not(:last-of-type)::after {
350
+ content: ',';
302
351
  }
303
352
 
304
353
  .info-model__social {
@@ -312,21 +361,36 @@ const shareModelCard = computed<IModelCard>(() => ({
312
361
  .info-model__header {
313
362
  display: grid;
314
363
  grid-area: header;
315
- row-gap: 8px;
316
- margin-bottom: 8px;
364
+ grid-template-columns: auto auto minmax(0, 1fr);
365
+ align-items: center;
366
+ gap: 8px;
367
+
368
+ @include from-br(sm) {
369
+ display: flex;
370
+ align-items: flex-start;
371
+ justify-content: space-between;
372
+ column-gap: 12px;
373
+ }
374
+ }
375
+
376
+ .info-model__heading {
377
+ display: contents;
317
378
 
318
379
  @include from-br(sm) {
319
380
  display: flex;
320
381
  flex-wrap: wrap;
321
382
  align-items: center;
322
- column-gap: 12px;
383
+ gap: 12px;
384
+ min-width: 0;
323
385
  }
324
386
  }
325
387
 
326
388
  .info-model__title {
327
389
  @include font-base;
328
390
 
391
+ grid-column: 1 / -1;
329
392
  min-width: 0;
393
+ margin-top: -2px;
330
394
  font-weight: 700;
331
395
  line-height: 1.25;
332
396
  text-transform: uppercase;
@@ -334,27 +398,22 @@ const shareModelCard = computed<IModelCard>(() => ({
334
398
 
335
399
  @include from-br(sm) {
336
400
  @include font-lg;
337
- }
338
- }
339
-
340
- .info-model__actions {
341
- display: flex;
342
- align-items: center;
343
- gap: 8px;
344
401
 
345
- @include from-br(sm) {
346
- display: contents;
402
+ margin-top: 0;
347
403
  }
348
404
  }
349
405
 
350
406
  .info-model__count {
351
- padding: 7px 12px;
407
+ min-height: 36px;
408
+ padding: 8px 12px;
352
409
  flex-shrink: 0;
353
410
  }
354
411
 
355
412
  .info-model__share {
356
413
  @include button-variant(info-model-button);
357
414
 
415
+ min-height: 36px;
416
+
358
417
  @include to-br(sm) {
359
418
  width: 36px;
360
419
  padding: 0;
@@ -368,19 +427,28 @@ const shareModelCard = computed<IModelCard>(() => ({
368
427
  }
369
428
 
370
429
  .info-model__follow {
371
- margin-inline-start: auto;
430
+ justify-self: end;
431
+
432
+ @include to-br(sm) {
433
+ width: 36px;
434
+ height: 36px;
435
+ }
436
+
437
+ @include from-br(sm) {
438
+ min-height: 36px;
439
+ }
372
440
  }
373
441
 
374
442
  .info-model__born {
375
443
  @include font-sm;
376
444
 
377
445
  grid-area: born;
378
- margin-top: 20px;
446
+ margin-top: 12px;
379
447
  color: $info-model-born;
448
+ line-height: 1.625;
380
449
 
381
450
  @include from-br(sm) {
382
- margin-top: 0;
383
- margin-bottom: 12px;
451
+ margin-top: 8px;
384
452
  }
385
453
  }
386
454
 
@@ -404,56 +472,93 @@ const shareModelCard = computed<IModelCard>(() => ({
404
472
  .info-model__description {
405
473
  @include font-sm;
406
474
 
407
- --line-clamp: 2;
408
-
409
475
  grid-area: description;
410
- margin-top: 16px;
476
+ max-width: 740px;
477
+ margin-top: 8px;
478
+ margin-bottom: -6px;
411
479
  line-height: 1.625;
412
480
  color: $info-model-description;
481
+ text-wrap: pretty;
413
482
 
414
483
  @include from-br(sm) {
415
- margin-top: 0;
484
+ margin-top: 12px;
416
485
  }
417
486
  }
418
487
 
419
- .info-model__parameters {
420
- grid-area: parameters;
421
- align-self: start;
488
+ .info-model__summary {
489
+ display: flex;
490
+ flex-wrap: wrap;
491
+ align-items: center;
492
+ grid-area: summary;
493
+ gap: 8px;
422
494
  margin-top: 16px;
495
+ }
423
496
 
424
- @include from-br(sm) {
425
- margin-top: 20px;
426
- }
497
+ .info-model__summary-item {
498
+ @include font-sm;
499
+
500
+ display: flex;
501
+ align-items: center;
502
+ column-gap: 4px;
503
+ height: 36px;
504
+ padding-inline: 10px;
505
+ border-radius: $radius-lg;
506
+ background-color: $info-model-summary-bg;
507
+ color: $info-model-summary-text;
427
508
  }
428
509
 
429
- .info-model__parameters .info-grid__item-title {
430
- @include to-br(sm) {
431
- display: none;
510
+ .info-model__summary-item.--link {
511
+ @include hover(background-color) {
512
+ background-color: $info-model-summary-bg--hover;
432
513
  }
433
514
  }
434
515
 
435
- .info-model__parameters .info-grid__item:not(.--link) .info-grid__item-value {
436
- @include to-br(sm) {
437
- border-left: none;
438
- }
516
+ .info-model__summary-icon {
517
+ flex-shrink: 0;
518
+ color: $info-model-summary-icon;
439
519
  }
440
520
 
441
- .info-model__parameters-extra {
442
- grid-area: extra;
521
+ .info-model__details {
522
+ display: grid;
523
+ grid-area: details;
524
+ row-gap: 20px;
443
525
  margin-top: 20px;
444
526
  }
445
527
 
446
- .info-model__parameters-extra.--collapsed {
528
+ .info-model__details.--collapsed {
447
529
  @include to-br(sm) {
448
530
  display: none;
449
531
  }
450
532
  }
451
533
 
452
- .info-model__footer {
453
- display: flex;
454
- justify-content: center;
455
- padding: 12px 20px;
456
- border-top: 1px solid $info-model-footer-border;
534
+ .info-model__details.--collapsed:not(.--about) {
535
+ display: none;
536
+ }
537
+
538
+ .info-model__details.--collapsed .info-model__parameters {
539
+ display: none;
540
+ }
541
+
542
+ .info-model__about-title {
543
+ @include font-xs;
544
+
545
+ margin-bottom: 12px;
546
+ color: $info-model-about-title;
547
+ font-weight: 600;
548
+ letter-spacing: var(--tracking-wide);
549
+ text-transform: uppercase;
550
+ }
551
+
552
+ .info-model__about-text {
553
+ @include font-sm;
554
+
555
+ --line-clamp: 3;
556
+
557
+ max-width: 740px;
558
+ margin-block: -6px;
559
+ line-height: 1.625;
560
+ color: $info-model-about-text;
561
+ text-wrap: pretty;
457
562
  }
458
563
 
459
564
  .info-model__more {
@@ -461,7 +566,11 @@ const shareModelCard = computed<IModelCard>(() => ({
461
566
 
462
567
  display: flex;
463
568
  align-items: center;
569
+ justify-self: start;
570
+ grid-area: more;
464
571
  column-gap: 6px;
572
+ margin-top: 16px;
573
+ margin-bottom: -6px;
465
574
  color: $info-model-more-text;
466
575
 
467
576
  @include hover(color) {
@@ -39,15 +39,10 @@ withDefaults(
39
39
  gap: 8px;
40
40
  flex-wrap: wrap;
41
41
  align-items: start;
42
-
43
- @include from-br(sm) {
44
- margin-inline: 0;
45
- gap: 12px;
46
- }
47
42
  }
48
43
 
49
44
  .info-socials__item {
50
- --size: 32px;
45
+ --size: 44px;
51
46
 
52
47
  display: grid;
53
48
  place-content: center;
@@ -58,19 +53,8 @@ withDefaults(
58
53
  border-radius: $radius-full;
59
54
  color: $info-socials-icon;
60
55
 
61
- @include from-br(sm) {
62
- --size: 28px;
63
- }
64
-
65
56
  @include hover(background-color) {
66
57
  background-color: $info-socials-bg--hover;
67
58
  }
68
59
  }
69
-
70
- .info-socials__item img {
71
- @include from-br(sm) {
72
- width: 14px;
73
- height: 14px;
74
- }
75
- }
76
60
  </style>
@@ -93,12 +93,6 @@
93
93
  class="view-model-videos__other-models"
94
94
  hydrate-on-visible
95
95
  />
96
-
97
- <LazyUiFullDescr
98
- v-if="isFirstPage && cardInfo?.fullDescription && !isFilteredPage"
99
- :text="cardInfo.fullDescription"
100
- hydrate-on-visible
101
- />
102
96
  </div>
103
97
  </template>
104
98
 
@@ -243,7 +237,7 @@ const infoVideosCount = computed(() => (props.dataOther && props.dataOther.items
243
237
 
244
238
  <style lang="scss">
245
239
  .view-model-videos__info {
246
- margin-bottom: 24px;
240
+ margin-bottom: 32px;
247
241
  }
248
242
 
249
243
  .view-model-videos__top-chips {
@@ -404,8 +404,7 @@ watch(() => JSON.stringify(toWebcamFilterPayload(route.query)), () => {
404
404
  margin-top: 0;
405
405
  }
406
406
 
407
- .grid-webcams.view-webcams__grid {
408
- // ресет обнуляет отступы у `ul[class]`, одиночный класс его не перебивает
407
+ .view-webcams__grid {
409
408
  margin-bottom: 40px;
410
409
  }
411
410
  </style>
@@ -1,4 +1,5 @@
1
1
  {
2
+ "model_about": "About",
2
3
  "top_models": "Top models",
3
4
  "playlists_empty": {
4
5
  "title": "No playlists yet",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1020",
4
+ "version": "0.0.1023",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -83,6 +83,7 @@
83
83
  "scripts/i18n-sheet.mjs",
84
84
  "scripts/site-updates.mjs",
85
85
  "scripts/link-layer.sh",
86
+ "scripts/spec.sh",
86
87
  "SITE_UPDATES.md",
87
88
  "nuxt.config.ts",
88
89
  "DOCS/"
@@ -1,7 +1,7 @@
1
- // Для сайта, после `npm run spec`: печатает записи SITE_UPDATES.md слоя, вышедшие после версии,
2
- // которая стояла в закоммиченном package.json сайта, и дописывает их в SITE_TODO.md сайта.
3
- // Запуск из корня сайта.
4
- // Явно: `node node_modules/itube-specs/scripts/site-updates.mjs <старая-версия>`.
1
+ // Для сайта, после `npm run spec`: печатает записи SITE_UPDATES.md слоя, вышедшие после старой версии,
2
+ // и дописывает их в SITE_TODO.md сайта. Запуск из корня сайта.
3
+ // Старая версия — аргументом (`spec` передаёт ту, что стояла в package.json до установки); без аргумента —
4
+ // из закоммиченного package.json.
5
5
 
6
6
  import { execSync } from 'node:child_process';
7
7
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
@@ -0,0 +1,38 @@
1
+ #!/bin/sh
2
+ # spec сайта: поставить последнюю опубликованную версию слоя, дописать её новые пункты SITE_UPDATES.md в
3
+ # SITE_TODO.md сайта, обновить каталог (если у сайта есть `npm run catalog`). Запуск из корня сайта.
4
+ # Версия берётся из реестра в обход кэша npm: сразу после публикации `@latest` из кэша давал прошлую.
5
+ # Отсчёт пунктов — от версии в package.json до установки, не от закоммиченной: повторный spec до коммита
6
+ # не возвращает сделанное.
7
+
8
+ set -e
9
+
10
+ prev=$(node -p "require('./package.json').dependencies['itube-specs'] || ''")
11
+
12
+ is_newer() {
13
+ node -e "
14
+ const p = v => v.replace(/^[^\\d]*/, '').split('.').map(Number);
15
+ const a = p(process.argv[1]), b = p(process.argv[2] || '0.0.0');
16
+ const i = a.findIndex((n, k) => n !== b[k]);
17
+ process.exit(i !== -1 && a[i] > b[i] ? 0 : 1);
18
+ " "$1" "$2"
19
+ }
20
+
21
+ for attempt in 1 2 3; do
22
+ latest=$(npm view itube-specs version --prefer-online 2>/dev/null) || { echo "spec: реестр npm недоступен"; exit 1; }
23
+ is_newer "$latest" "$prev" && break
24
+ if [ "$attempt" = 3 ]; then
25
+ echo "spec: в реестре $latest, новее $prev нет. Только что опубликовал — подожди минуту и повтори."
26
+ exit 0
27
+ fi
28
+ echo "spec: в реестре пока $latest, жду новую версию…"
29
+ sleep 10
30
+ done
31
+
32
+ npm install "itube-specs@$latest" --prefer-online
33
+
34
+ node node_modules/itube-specs/scripts/site-updates.mjs "$prev"
35
+
36
+ if node -e "process.exit(require('./package.json').scripts?.catalog ? 0 : 1)"; then
37
+ (npx nuxi prepare >/dev/null 2>&1 && npm run catalog --silent) || echo "spec: каталог не обновлён — запусти \`npx nuxi prepare && npm run catalog\` на Node 22"
38
+ fi
@@ -0,0 +1,16 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { modelFilterLink } from '../model-filter-link';
3
+
4
+ describe('modelFilterLink', () => {
5
+ it('builds a single filter for a select', () => {
6
+ expect(modelFilterLink({ name: 'hair_color', kind: 'select' }, 'brown')).toBe('/models?filter_hair_color=brown');
7
+ });
8
+
9
+ it('puts a range value into both _from and _to', () => {
10
+ expect(modelFilterLink({ name: 'age', kind: 'range' }, '27')).toBe('/models?filter_age_from=27&filter_age_to=27');
11
+ });
12
+
13
+ it('lowercases and joins words with +', () => {
14
+ expect(modelFilterLink({ name: 'interested_in', kind: 'radio' }, 'Guys And Girls')).toBe('/models?filter_interested_in=guys+and+girls');
15
+ });
16
+ });
@@ -0,0 +1,17 @@
1
+ import type { IGroupedParameterItem } from '../../types';
2
+
3
+ /**
4
+ * Ссылка на каталог моделей, отфильтрованный по значению параметра. У диапазона
5
+ * значение уходит сразу в `_from` и `_to`.
6
+ * @param item - параметр: `name` — ключ фильтра, `kind` — тип
7
+ * @param value - `name` значения параметра
8
+ */
9
+ export function modelFilterLink(item: Pick<IGroupedParameterItem, 'name' | 'kind'>, value: string) {
10
+ const formattedValue = value.toLowerCase().replace(/\s+/g, '+');
11
+
12
+ if (item.kind === 'range') {
13
+ return `/models?filter_${item.name}_from=${formattedValue}&filter_${item.name}_to=${formattedValue}`;
14
+ }
15
+
16
+ return `/models?filter_${item.name}=${formattedValue}`;
17
+ }