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.
- package/SITE_UPDATES.md +27 -0
- package/assets/scss/base/_normalize.scss +3 -6
- package/assets/scss/themes/dark/_theme.scss +18 -7
- package/assets/scss/themes/light/_theme.scss +14 -3
- package/components/card/video/index.vue +4 -0
- package/components/card/webcam/index.vue +2 -4
- package/components/info/grid.vue +19 -19
- package/components/info/model.vue +289 -180
- package/components/info/socials.vue +1 -17
- package/components/player/channel.vue +19 -8
- package/components/player/interface.vue +17 -13
- package/components/player/like.vue +2 -2
- package/components/player/tags.vue +224 -0
- package/components/view/model-videos.vue +1 -7
- package/components/view/video.vue +2 -2
- package/components/view/webcams.vue +1 -2
- package/i18n/locales/en-US.json +1 -0
- package/package.json +1 -1
- package/utils/info-grid/__tests__/model-filter-link.test.ts +16 -0
- package/utils/info-grid/model-filter-link.ts +17 -0
|
@@ -73,9 +73,9 @@ function onSubscribeClick() {
|
|
|
73
73
|
.player-channel {
|
|
74
74
|
display: flex;
|
|
75
75
|
align-items: center;
|
|
76
|
-
column-gap:
|
|
76
|
+
column-gap: 14px;
|
|
77
77
|
height: var(--chips-height);
|
|
78
|
-
padding-right:
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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:
|
|
228
|
-
|
|
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
|
|
276
|
-
align-items:
|
|
277
|
-
column-gap:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
.
|
|
408
|
-
// ресет обнуляет отступы у `ul[class]`, одиночный класс его не перебивает
|
|
407
|
+
.view-webcams__grid {
|
|
409
408
|
margin-bottom: 40px;
|
|
410
409
|
}
|
|
411
410
|
</style>
|
package/i18n/locales/en-US.json
CHANGED
package/package.json
CHANGED
|
@@ -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
|
+
}
|