itube-specs 0.0.1018 → 0.0.1021

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.
@@ -73,9 +73,9 @@ function onSubscribeClick() {
73
73
  .player-channel {
74
74
  display: flex;
75
75
  align-items: center;
76
- column-gap: 8px;
76
+ column-gap: 14px;
77
77
  height: var(--chips-height);
78
- padding-right: 8px;
78
+ padding-right: 14px;
79
79
  background-color: $video-channel-bg;
80
80
  border-radius: $radius;
81
81
  overflow: hidden;
@@ -95,7 +95,7 @@ function onSubscribeClick() {
95
95
  }
96
96
 
97
97
  .player-channel__initials {
98
- @include font-3xs;
98
+ @include font-4xs;
99
99
 
100
100
  display: flex;
101
101
  align-items: center;
@@ -109,19 +109,22 @@ function onSubscribeClick() {
109
109
  }
110
110
 
111
111
  .player-channel__name {
112
- @include font-sm;
112
+ @include font-2xs;
113
113
 
114
114
  color: $video-channel-name;
115
- transition: color var(--transition);
116
115
  white-space: nowrap;
117
116
 
118
- &:hover {
117
+ @include from-br(sm) {
118
+ @include font-sm;
119
+ }
120
+
121
+ @include hover(color) {
119
122
  color: $video-channel-name--hover;
120
123
  }
121
124
  }
122
125
 
123
126
  .player-channel__subscribe {
124
- @include font-xs;
127
+ @include font-2xs;
125
128
 
126
129
  flex-shrink: 0;
127
130
  color: $video-channel-subscribe-text;
@@ -132,15 +135,23 @@ function onSubscribeClick() {
132
135
  background-color: $video-channel-subscribe-bg;
133
136
  border-radius: $radius-md;
134
137
 
138
+ @include from-br(sm) {
139
+ @include font-sm;
140
+ }
141
+
135
142
  @include hover(background-color) {
136
143
  background-color: $video-channel-subscribe-bg--hover;
137
144
  }
138
145
  }
139
146
 
140
147
  .player-channel__count {
141
- @include font-xs;
148
+ @include font-2xs;
142
149
 
143
150
  color: $video-channel-count;
144
151
  white-space: nowrap;
152
+
153
+ @include from-br(sm) {
154
+ @include font-sm;
155
+ }
145
156
  }
146
157
  </style>
@@ -188,6 +188,7 @@ function onAddPlaylistClick() {
188
188
 
189
189
  @include from-br(sm) {
190
190
  display: flex;
191
+ margin-left: -6px;
191
192
  }
192
193
  }
193
194
 
@@ -224,8 +225,8 @@ function onAddPlaylistClick() {
224
225
  align-items: center;
225
226
  row-gap: 0;
226
227
  column-gap: 6px;
227
- padding: 6px 12px;
228
- font-weight: 500;
228
+ padding: 8px 12px;
229
+ border-radius: $radius-md;
229
230
  white-space: nowrap;
230
231
  background-color: var(--button-bg, #{$video-interface-button-bg});
231
232
  color: var(--button-text, #{$video-interface-button-text});
@@ -272,26 +273,29 @@ function onAddPlaylistClick() {
272
273
 
273
274
  .player-interface__description {
274
275
  display: grid;
275
- grid-template-columns: 1fr 20px;
276
- align-items: center;
277
- column-gap: 8px;
276
+ grid-template-columns: 1fr auto;
277
+ align-items: end;
278
+ column-gap: 12px;
278
279
  }
279
280
 
280
281
  .player-interface__description-handler {
281
- height: 1lh;
282
+ @include font-sm;
283
+
284
+ display: flex;
285
+ align-items: center;
286
+ height: 1.625em;
287
+ padding-left: 6px;
282
288
  color: $video-info-handler-text;
283
289
  cursor: pointer;
290
+
291
+ @include hover(color) {
292
+ color: $video-info-handler-text--hover;
293
+ }
284
294
  }
285
295
 
286
296
  .player-interface__description-icon {
297
+ margin-right: -4px;
287
298
  transition: var(--transition) transform;
288
-
289
- @include from-br(sm) {
290
- $size: 16px;
291
-
292
- width: $size;
293
- height: $size;
294
- }
295
299
  }
296
300
 
297
301
  .player-interface__description-text {
@@ -149,8 +149,8 @@ const likePercent = computed(() => getReactionPercents(resultLikes.value, result
149
149
  @include from-br(sm) {
150
150
  flex-direction: row;
151
151
  row-gap: 0;
152
- column-gap: 6px;
153
- padding: 6px;
152
+ column-gap: 12px;
153
+ padding: 6px 0 6px 6px;
154
154
  border-radius: $radius-small;
155
155
  color: $like-icon;
156
156
  }
@@ -0,0 +1,224 @@
1
+ <!-- Строка над плеером: канал и модели (слот), категории и теги ролика. Видно столько чипсов, сколько
2
+ влезло в строку вместе с кнопкой «More +N»; остальные остаются в разметке (их читает краулер), но скрыты
3
+ до раскрытия. На мобиле строка листается вбок, видны первые MOBILE_VISIBLE чипсов. -->
4
+ <template>
5
+ <div
6
+ ref="rowRef"
7
+ :class="{'--open': isOpen}"
8
+ class="player-tags"
9
+ >
10
+ <slot name="prepend" />
11
+ <UiChips
12
+ v-for="(item, index) in items"
13
+ :key="`${index}-${item.title}`"
14
+ :item="item"
15
+ :class="{
16
+ '--tag': item.prefix === 'tag',
17
+ '--hidden': !isOpen && index >= visibleCount,
18
+ }"
19
+ class="player-tags__chip"
20
+ is-link
21
+ />
22
+ <button
23
+ v-show="hiddenCount > 0 || isOpen"
24
+ ref="moreRef"
25
+ :aria-expanded="isOpen"
26
+ class="player-tags__more"
27
+ type="button"
28
+ @click="isOpen = !isOpen"
29
+ >
30
+ {{ moreLabel }}
31
+ <UiIcon
32
+ class="player-tags__more-icon"
33
+ name="chevron-down"
34
+ size="12"
35
+ />
36
+ </button>
37
+ </div>
38
+ </template>
39
+
40
+ <script setup lang="ts">
41
+ import type { IChipsItem } from '../../types';
42
+ import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
43
+
44
+ const props = defineProps<{
45
+ items: IChipsItem[]
46
+ }>();
47
+
48
+ /** Сколько чипсов видно на мобиле до раскрытия — строка там листается, «влезло» не считается. */
49
+ const MOBILE_VISIBLE = 4;
50
+
51
+ const { t } = useI18n();
52
+
53
+ const rowRef = ref<HTMLElement>();
54
+ const moreRef = ref<HTMLElement>();
55
+ const isOpen = ref(false);
56
+ // До замера на клиенте видны все — разметка сервера и первого рендера клиента совпадает
57
+ const visibleCount = ref(props.items.length);
58
+
59
+ const hiddenCount = computed(() => Math.max(props.items.length - visibleCount.value, 0));
60
+
61
+ const upperFirst = (text: string) => text.charAt(0).toUpperCase() + text.slice(1);
62
+ const moreLabel = computed(() => upperFirst(isOpen.value ? t('show_less') : `${t('more')} +${hiddenCount.value}`));
63
+
64
+ /** Ширины чипсов меряются один раз, пока все видны: скрытые `display: none` ширины не имеют. */
65
+ let chipWidths: number[] = [];
66
+
67
+ function measureChips() {
68
+ const row = rowRef.value;
69
+ if (!row) return;
70
+
71
+ chipWidths = Array.from(row.querySelectorAll<HTMLElement>(':scope > .player-tags__chip')).map(chip => chip.offsetWidth);
72
+ }
73
+
74
+ function layout() {
75
+ const row = rowRef.value;
76
+ if (!row || isOpen.value) return;
77
+
78
+ if (window.innerWidth < CSS_BREAKPOINTS.sm) {
79
+ visibleCount.value = Math.min(MOBILE_VISIBLE, props.items.length);
80
+ return;
81
+ }
82
+
83
+ const gap = parseFloat(getComputedStyle(row).columnGap) || 0;
84
+ const prependWidth = (Array.from(row.children) as HTMLElement[])
85
+ .filter(child => !child.classList.contains('player-tags__chip') && child !== moreRef.value && child.offsetWidth > 0)
86
+ .reduce((sum, child) => sum + child.offsetWidth + gap, 0);
87
+ const available = row.clientWidth - prependWidth;
88
+ const total = chipWidths.reduce((sum, width) => sum + width + gap, 0) - gap;
89
+
90
+ if (total <= available) {
91
+ visibleCount.value = props.items.length;
92
+ return;
93
+ }
94
+
95
+ // ponytail: ширина кнопки — по тексту «More +88»; при другом числе цифр расхождение в пару px
96
+ const moreWidth = moreRef.value?.offsetWidth || 0;
97
+ let used = 0;
98
+ let count = 0;
99
+
100
+ for (const width of chipWidths) {
101
+ if (used + width + gap + moreWidth > available) break;
102
+ used += width + gap;
103
+ count++;
104
+ }
105
+
106
+ visibleCount.value = count;
107
+ }
108
+
109
+ async function remeasure() {
110
+ isOpen.value = false;
111
+ visibleCount.value = props.items.length;
112
+ await nextTick();
113
+ measureChips();
114
+ layout();
115
+ // кнопка показалась только сейчас — с её реальной шириной раскладка может сдвинуться на чипсу
116
+ await nextTick();
117
+ layout();
118
+ }
119
+
120
+ watch(() => props.items, remeasure);
121
+
122
+ let resizeObserver: ResizeObserver | null = null;
123
+
124
+ onMounted(() => {
125
+ remeasure();
126
+ resizeObserver = new ResizeObserver(() => layout());
127
+ if (rowRef.value) resizeObserver.observe(rowRef.value);
128
+ });
129
+
130
+ onBeforeUnmount(() => {
131
+ resizeObserver?.disconnect();
132
+ });
133
+ </script>
134
+
135
+ <style lang="scss">
136
+ .player-tags {
137
+ --chips-height: 36px;
138
+
139
+ /* строка не растягивает предков своей шириной: на мобиле она листается, а не раздвигает страницу */
140
+ contain: inline-size;
141
+ display: flex;
142
+ align-items: center;
143
+ gap: 8px;
144
+ margin-inline: calc(-1 * var(--container-padding-inline));
145
+ padding-inline: var(--container-padding-inline);
146
+ overflow-x: auto;
147
+ scrollbar-width: none;
148
+
149
+ @include from-br(sm) {
150
+ flex-wrap: wrap;
151
+ margin-inline: 0;
152
+ padding-inline: 0;
153
+ overflow: visible;
154
+ }
155
+ }
156
+
157
+ .player-tags::-webkit-scrollbar {
158
+ display: none;
159
+ }
160
+
161
+ /* До замера на клиенте строка — одна линия, без мигания второго ряда */
162
+ .player-tags:not(.--open) {
163
+ @include from-br(sm) {
164
+ max-height: var(--chips-height);
165
+ overflow: hidden;
166
+ }
167
+ }
168
+
169
+ .player-tags .ui-chips {
170
+ @include font-2xs;
171
+
172
+ flex-shrink: 0;
173
+ border: 1px solid transparent;
174
+ border-radius: $radius;
175
+ background-color: $player-tags-chip-bg;
176
+
177
+ @include from-br(sm) {
178
+ @include font-sm;
179
+ }
180
+ }
181
+
182
+ .player-tags .ui-chips.--tag {
183
+ background-color: $player-tags-tag-bg;
184
+ }
185
+
186
+ .player-tags__chip.--hidden {
187
+ display: none;
188
+ }
189
+
190
+ .player-tags__more {
191
+ @include font-2xs;
192
+
193
+ display: flex;
194
+ flex-shrink: 0;
195
+ align-items: center;
196
+ column-gap: 4px;
197
+ padding: 6px 4px;
198
+ color: $player-tags-more-text;
199
+ white-space: nowrap;
200
+
201
+ @include from-br(sm) {
202
+ @include font-sm;
203
+ }
204
+
205
+ @include hover(color) {
206
+ color: $player-tags-more-text--hover;
207
+ }
208
+ }
209
+
210
+ .player-tags__more-icon {
211
+ width: 14px;
212
+ height: 14px;
213
+ transition: var(--transition) transform;
214
+
215
+ @include from-br(sm) {
216
+ width: 12px;
217
+ height: 12px;
218
+ }
219
+ }
220
+
221
+ .player-tags.--open .player-tags__more-icon {
222
+ transform: rotate(180deg);
223
+ }
224
+ </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 {
@@ -6,7 +6,7 @@
6
6
  <span class="_truncate">{{ data.title }}</span>
7
7
  </h1>
8
8
  <div class="view-video__header-wrapper">
9
- <UiExpandRow
9
+ <PlayerTags
10
10
  v-if="topChips && topChips.length > 0"
11
11
  :items="topChips"
12
12
  class="view-video__header-categories"
@@ -27,7 +27,7 @@
27
27
  />
28
28
  </template>
29
29
  </template>
30
- </UiExpandRow>
30
+ </PlayerTags>
31
31
  </div>
32
32
  </header>
33
33
 
@@ -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.1018",
4
+ "version": "0.0.1021",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -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
+ }