itube-specs 0.0.896 → 0.0.898
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/assets/icons/common/bell.svg +1 -0
- package/assets/scss/mixins/_font-sizes.scss +5 -0
- package/assets/scss/themes/dark/_theme.scss +38 -23
- package/assets/scss/themes/light/_theme.scss +34 -21
- package/assets/scss/vars/_palette.scss +1 -0
- package/assets/scss/vars/_tokens.scss +2 -0
- package/components/card/category.vue +22 -1
- package/components/card/playlist.vue +32 -4
- package/components/card/round.vue +48 -4
- package/components/filter/main.vue +2 -6
- package/components/filter/model.vue +2 -6
- package/components/grid/playlists.vue +2 -0
- package/components/layout/burger-item.vue +8 -8
- package/components/layout/extra-links.vue +2 -0
- package/components/layout/header.vue +29 -3
- package/components/layout/logo.vue +11 -2
- package/components/layout/notifications.vue +79 -0
- package/components/section/swiper-cards.vue +65 -34
- package/components/ui/btn.vue +15 -0
- package/components/ui/popup.vue +0 -4
- package/components/ui/swiper.vue +19 -4
- package/components/ui/view-all.vue +113 -0
- package/composables/use-meta.ts +67 -28
- package/package.json +1 -1
- package/pages/2257.vue +1 -1
- package/pages/categories/all.vue +1 -1
- package/pages/categories/index.vue +1 -1
- package/pages/categories/letter/[slug].vue +7 -1
- package/pages/channels/[channel].vue +8 -1
- package/pages/channels/index.vue +6 -1
- package/pages/confirm/registration/[token].vue +1 -1
- package/pages/contacts.vue +1 -1
- package/pages/dmca.vue +1 -1
- package/pages/following.vue +201 -0
- package/pages/index.vue +8 -10
- package/pages/library/index.vue +5 -2
- package/pages/models/[slug]/channel/[channel].vue +9 -1
- package/pages/models/[slug]/index.vue +9 -1
- package/pages/models/[slug]/tag/[tag].vue +10 -1
- package/pages/models/index.vue +7 -1
- package/pages/models/letter/[slug].vue +8 -1
- package/pages/playlists/[playlistId]/[slug].vue +9 -1
- package/pages/playlists/index.vue +6 -1
- package/pages/policy.vue +1 -1
- package/pages/search/[slug]/index.vue +26 -38
- package/pages/search/[slug]/models.vue +7 -1
- package/pages/search/[slug]/videos.vue +7 -1
- package/pages/tag/[tag].vue +8 -1
- package/pages/terms.vue +1 -1
- package/pages/user/profile.vue +1 -1
- package/pages/user/recovery-password.vue +1 -1
- package/pages/user/settings.vue +1 -1
- package/pages/videos/[slug].vue +8 -1
- package/runtime/utils/cleaners/__tests__/clean-model-info.test.ts +13 -0
- package/runtime/utils/cleaners/clean-model-info.ts +1 -0
- package/server/api/models/list-by-niche.post.ts +3 -1
- package/stubs/library-follows.ts +75 -2
- package/types/button-themes.d.ts +1 -1
- package/types/card-info.d.ts +2 -0
- package/types/category-card.d.ts +2 -0
- package/types/index.d.ts +1 -0
- package/types/page-seo.d.ts +6 -0
- package/types/paginated-response.d.ts +3 -0
- package/types/playlist-card.d.ts +2 -0
- package/types/raw/raw-model-info.d.ts +2 -1
- package/types/round-card.d.ts +2 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<!-- Колокольчик уведомлений в шапке: ссылка на подписки + счётчик обновлений. Данные пока заглушка -->
|
|
2
|
+
<template>
|
|
3
|
+
<NuxtLink
|
|
4
|
+
:to="generateLink('/following')"
|
|
5
|
+
:aria-label="t('notifications')"
|
|
6
|
+
:title="t('notifications')"
|
|
7
|
+
class="layout-notifications"
|
|
8
|
+
>
|
|
9
|
+
<UiIcon
|
|
10
|
+
class="layout-notifications__icon"
|
|
11
|
+
name="bell"
|
|
12
|
+
size="24"
|
|
13
|
+
/>
|
|
14
|
+
<span
|
|
15
|
+
v-if="count > 0"
|
|
16
|
+
class="layout-notifications__badge"
|
|
17
|
+
>{{ badge }}</span>
|
|
18
|
+
</NuxtLink>
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<script setup lang="ts">
|
|
22
|
+
import { getFollowingUpdatesTotal } from '../../stubs/library-follows';
|
|
23
|
+
|
|
24
|
+
const MAX_COUNT = 99;
|
|
25
|
+
|
|
26
|
+
const { t } = useI18n();
|
|
27
|
+
const { generateLink } = useGenerateLink();
|
|
28
|
+
|
|
29
|
+
const count = computed(() => getFollowingUpdatesTotal());
|
|
30
|
+
|
|
31
|
+
const badge = computed(() => count.value > MAX_COUNT ? `${MAX_COUNT}+` : `${count.value}`);
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<style lang="scss">
|
|
35
|
+
/* Бейдж лежит в потоке и наезжает на иконку отрицательным отступом: разряды уходят
|
|
36
|
+
вправо, растягивая контрол. Место под «99+» не резервируется, а зазор от края
|
|
37
|
+
бейджа до соседа в шапке одинаков при любом числе — его держит $badge-inset. */
|
|
38
|
+
$padding: 8px;
|
|
39
|
+
$badge-inset: 2px;
|
|
40
|
+
$badge-overlap: 10px;
|
|
41
|
+
$badge-raise: 6px;
|
|
42
|
+
|
|
43
|
+
.layout-notifications {
|
|
44
|
+
display: inline-flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
padding: $padding $badge-inset $padding $padding;
|
|
47
|
+
color: $notifications-icon;
|
|
48
|
+
|
|
49
|
+
/* На мобиле это такой же тап-контрол, как бургер рядом */
|
|
50
|
+
@include to-br(sm) {
|
|
51
|
+
color: $notifications-icon--mobile;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@include hover(color) {
|
|
55
|
+
color: $notifications-icon--hover;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.layout-notifications__badge {
|
|
60
|
+
@include font-4xs;
|
|
61
|
+
|
|
62
|
+
/* Левым краем на верхний правый угол иконки, дальше растёт вправо */
|
|
63
|
+
position: relative;
|
|
64
|
+
z-index: $z-index-plus;
|
|
65
|
+
display: grid;
|
|
66
|
+
place-content: center;
|
|
67
|
+
align-self: flex-start;
|
|
68
|
+
min-width: 16px;
|
|
69
|
+
height: 16px;
|
|
70
|
+
margin-top: -$badge-raise;
|
|
71
|
+
margin-left: -$badge-overlap;
|
|
72
|
+
padding-inline: 4px;
|
|
73
|
+
font-weight: 700;
|
|
74
|
+
color: $notifications-badge-text;
|
|
75
|
+
background-color: $notifications-badge-bg;
|
|
76
|
+
border-radius: $radius-full;
|
|
77
|
+
box-shadow: 0 0 0 2px $notifications-badge-ring;
|
|
78
|
+
}
|
|
79
|
+
</style>
|
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
<!-- Горизонтальный свайпер аватаров моделей с заголовком и ссылкой «все» -->
|
|
2
2
|
<template>
|
|
3
3
|
<section class="section-swiper-cards">
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
<header class="section-swiper-cards__header">
|
|
5
|
+
<UiSectionTitle
|
|
6
|
+
:title="title || t('top_models')"
|
|
7
|
+
:count="count ?? items.length"
|
|
8
|
+
:icon="icon"
|
|
9
|
+
class="section-swiper-cards__title"
|
|
10
|
+
title-tag="h2"
|
|
11
|
+
/>
|
|
12
|
+
|
|
13
|
+
<!-- На мобиле плитки «все» в ленте нет, поэтому ссылка вынесена в шапку -->
|
|
14
|
+
<UiLink
|
|
15
|
+
v-if="allLink"
|
|
16
|
+
:to="generateLink(allLink)"
|
|
17
|
+
class="section-swiper-cards__link _to-sm"
|
|
18
|
+
theme="simple"
|
|
19
|
+
>
|
|
20
|
+
{{ headerLinkText ?? allLinkText }}
|
|
21
|
+
<UiIcon
|
|
22
|
+
name="arrow-right"
|
|
23
|
+
size="16"
|
|
24
|
+
/>
|
|
25
|
+
</UiLink>
|
|
26
|
+
</header>
|
|
11
27
|
<div class="section-swiper-cards__grid">
|
|
12
28
|
<UiSwiper
|
|
13
29
|
:items="items"
|
|
@@ -17,6 +33,7 @@
|
|
|
17
33
|
<CardRound
|
|
18
34
|
:card="item"
|
|
19
35
|
:placeholder-avatar="placeholderAvatar"
|
|
36
|
+
:without-count="withoutCount"
|
|
20
37
|
class="section-swiper-cards__card"
|
|
21
38
|
/>
|
|
22
39
|
</template>
|
|
@@ -24,11 +41,12 @@
|
|
|
24
41
|
v-if="allLink"
|
|
25
42
|
#append
|
|
26
43
|
>
|
|
27
|
-
<
|
|
28
|
-
:to="
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
44
|
+
<UiViewAll
|
|
45
|
+
:to="allLink ?? '/'"
|
|
46
|
+
:text="allLinkText ?? ''"
|
|
47
|
+
:note="allLinkNote"
|
|
48
|
+
mini
|
|
49
|
+
/>
|
|
32
50
|
</template>
|
|
33
51
|
</UiSwiper>
|
|
34
52
|
</div>
|
|
@@ -38,14 +56,25 @@
|
|
|
38
56
|
<script setup lang="ts">
|
|
39
57
|
import type { IRoundCard } from '../../types';
|
|
40
58
|
|
|
41
|
-
defineProps<{
|
|
59
|
+
withDefaults(defineProps<{
|
|
42
60
|
items: IRoundCard[]
|
|
43
61
|
onlyGrid?: boolean
|
|
44
62
|
allLink?: string
|
|
45
63
|
allLinkText?: string
|
|
64
|
+
/** Вторая строка в плитке «все» — «16 models» */
|
|
65
|
+
allLinkNote?: string
|
|
66
|
+
/** Подпись ссылки в шапке («All models»); по умолчанию — как в плитке */
|
|
67
|
+
headerLinkText?: string
|
|
46
68
|
title?: string
|
|
69
|
+
/** Счётчик в шапке; по умолчанию — число карточек */
|
|
70
|
+
count?: string | number
|
|
71
|
+
icon?: string
|
|
47
72
|
placeholderAvatar?: string
|
|
48
|
-
|
|
73
|
+
/** Карточки только с аватаром и названием, без счётчика роликов */
|
|
74
|
+
withoutCount?: boolean
|
|
75
|
+
}>(), {
|
|
76
|
+
icon: 'user'
|
|
77
|
+
});
|
|
49
78
|
|
|
50
79
|
const { t } = useI18n();
|
|
51
80
|
const { generateLink } = useGenerateLink();
|
|
@@ -58,12 +87,22 @@ const { generateLink } = useGenerateLink();
|
|
|
58
87
|
min-width: 0;
|
|
59
88
|
}
|
|
60
89
|
|
|
90
|
+
.section-swiper-cards__header {
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: space-between;
|
|
94
|
+
column-gap: 16px;
|
|
95
|
+
margin-bottom: 16px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* text-transform здесь не нужен: капс и трекинг заголовку даёт его собственный
|
|
99
|
+
`._title`, а на корне он красил заодно и чип счётчика («6 UPDATES»). */
|
|
61
100
|
.section-swiper-cards__title {
|
|
62
101
|
@include font-lg;
|
|
102
|
+
}
|
|
63
103
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
letter-spacing: var(--tracking-wide);
|
|
104
|
+
.section-swiper-cards__link {
|
|
105
|
+
flex-shrink: 0;
|
|
67
106
|
}
|
|
68
107
|
|
|
69
108
|
.section-swiper-cards .section-swiper-cards__card {
|
|
@@ -74,24 +113,16 @@ const { generateLink } = useGenerateLink();
|
|
|
74
113
|
min-width: 0;
|
|
75
114
|
}
|
|
76
115
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
place-content: center;
|
|
82
|
-
width: $card-round-size;
|
|
83
|
-
height: $card-round-size;
|
|
84
|
-
aspect-ratio: 1/1;
|
|
85
|
-
border-radius: $radius-full;
|
|
86
|
-
object-fit: cover;
|
|
87
|
-
border: 2px solid $card-round-border;
|
|
88
|
-
background-color: $card-round-all-bg;
|
|
89
|
-
color: $card-round-all-text;
|
|
90
|
-
text-align: center;
|
|
116
|
+
/* Слайд ровно по круглой карточке: 80px на мобиле, 96px с sm */
|
|
117
|
+
.section-swiper-cards .ui-swiper__slide {
|
|
118
|
+
width: var(--card-round-size);
|
|
119
|
+
}
|
|
91
120
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
121
|
+
/* Плитка «все» — только с sm, на мобиле её роль играет ссылка в шапке */
|
|
122
|
+
.section-swiper-cards .ui-swiper__slide--append {
|
|
123
|
+
@include to-br(sm) {
|
|
124
|
+
display: none;
|
|
95
125
|
}
|
|
96
126
|
}
|
|
127
|
+
|
|
97
128
|
</style>
|
package/components/ui/btn.vue
CHANGED
|
@@ -161,4 +161,19 @@ const slotText = computed(() => {
|
|
|
161
161
|
background-color: var(--button-bg--hover, #{$button-warning-bg--hover});
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
|
+
|
|
165
|
+
/* Без подложки, рамки и паддингов — обычная текстовая ссылка с иконкой */
|
|
166
|
+
.ui-btn.--simple {
|
|
167
|
+
min-height: 0;
|
|
168
|
+
padding: 0;
|
|
169
|
+
column-gap: 4px;
|
|
170
|
+
color: var(--button-text, #{$button-simple-text});
|
|
171
|
+
background-color: transparent;
|
|
172
|
+
border-color: transparent;
|
|
173
|
+
border-radius: 0;
|
|
174
|
+
|
|
175
|
+
@include hover(color) {
|
|
176
|
+
color: var(--button-text--hover, #{$button-simple-text--hover});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
164
179
|
</style>
|
package/components/ui/popup.vue
CHANGED
|
@@ -313,10 +313,6 @@ const { snackbarText, isSnackBarInPopup } = useSnackbar();
|
|
|
313
313
|
.ui-popup__footer {
|
|
314
314
|
padding: 16px 20px;
|
|
315
315
|
border-top: 1px solid $popup-footer-border;
|
|
316
|
-
|
|
317
|
-
@include from-br(sm) {
|
|
318
|
-
padding: 16px 24px;
|
|
319
|
-
}
|
|
320
316
|
}
|
|
321
317
|
|
|
322
318
|
.ui-popup[open].--drawer {
|
package/components/ui/swiper.vue
CHANGED
|
@@ -97,6 +97,10 @@ onMounted(() => {
|
|
|
97
97
|
</script>
|
|
98
98
|
|
|
99
99
|
<style lang="scss">
|
|
100
|
+
/* Запас под ring ховера карточек: у трека overflow-x: auto, а значит по спецификации
|
|
101
|
+
и overflow-y становится auto — без запаса тень обрезается по всем краям. */
|
|
102
|
+
$ring-room: 4px;
|
|
103
|
+
|
|
100
104
|
.ui-swiper {
|
|
101
105
|
position: relative;
|
|
102
106
|
|
|
@@ -113,8 +117,13 @@ onMounted(() => {
|
|
|
113
117
|
scroll-snap-type: x mandatory;
|
|
114
118
|
-webkit-overflow-scrolling: touch;
|
|
115
119
|
scrollbar-width: none;
|
|
120
|
+
padding-block: $ring-room;
|
|
116
121
|
padding-inline: var(--container-padding-inline);
|
|
117
122
|
|
|
123
|
+
/* Без scroll-padding mandatory-снап считает точкой привязки сам край скроллпорта
|
|
124
|
+
и на старте подтягивает первую карточку под padding — она липнет к краю вьюпорта. */
|
|
125
|
+
scroll-padding-inline: var(--container-padding-inline);
|
|
126
|
+
|
|
118
127
|
&::-webkit-scrollbar {
|
|
119
128
|
display: none;
|
|
120
129
|
}
|
|
@@ -123,9 +132,12 @@ onMounted(() => {
|
|
|
123
132
|
gap: 8px;
|
|
124
133
|
}
|
|
125
134
|
|
|
135
|
+
/* Отрицательный margin гасит боковой запас, чтобы первая карточка осталась по краю */
|
|
126
136
|
@include from-br(sm) {
|
|
127
137
|
gap: 16px;
|
|
128
|
-
padding-inline:
|
|
138
|
+
padding-inline: $ring-room;
|
|
139
|
+
scroll-padding-inline: $ring-room;
|
|
140
|
+
margin-inline: -$ring-room;
|
|
129
141
|
}
|
|
130
142
|
}
|
|
131
143
|
|
|
@@ -144,7 +156,7 @@ $nav-gap: 4px;
|
|
|
144
156
|
$size: 40px;
|
|
145
157
|
|
|
146
158
|
position: absolute;
|
|
147
|
-
top: calc(var(--slide-width) / 2);
|
|
159
|
+
top: calc(var(--slide-width) / 2 + #{$ring-room});
|
|
148
160
|
transform: translateY(-50%);
|
|
149
161
|
z-index: calc($z-index-plus + 2);
|
|
150
162
|
display: grid;
|
|
@@ -165,15 +177,18 @@ $nav-gap: 4px;
|
|
|
165
177
|
background-color: $swiper-nav-bg--hover;
|
|
166
178
|
}
|
|
167
179
|
|
|
180
|
+
/* Трек вылезает за .ui-swiper на $ring-room, поэтому градиент уводим на
|
|
181
|
+
($nav-gap + $ring-room) — иначе между затемнением и краем свайпера
|
|
182
|
+
остаётся полоска непокрытых карточек. */
|
|
168
183
|
&::before {
|
|
169
184
|
position: absolute;
|
|
170
185
|
content: '';
|
|
171
186
|
top: 50%;
|
|
172
|
-
right: $nav-gap
|
|
187
|
+
right: -($nav-gap + $ring-room);
|
|
173
188
|
transform: translateY(-50%);
|
|
174
189
|
display: block;
|
|
175
190
|
width: 64px;
|
|
176
|
-
height: var(--slide-width);
|
|
191
|
+
height: calc(var(--slide-width) + #{$ring-room * 2});
|
|
177
192
|
background: linear-gradient(270deg, $swiper-fade 0%, $swiper-fade-end 100%);
|
|
178
193
|
}
|
|
179
194
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<!-- Плитка-ссылка «смотреть все» в конце ленты: точечная текстура, стрелка и подпись. mini — круглая, под свайпер аватаров -->
|
|
2
|
+
<template>
|
|
3
|
+
<NuxtLink
|
|
4
|
+
:class="{'--mini': mini}"
|
|
5
|
+
:to="generateLink(to)"
|
|
6
|
+
class="ui-view-all"
|
|
7
|
+
>
|
|
8
|
+
<span
|
|
9
|
+
v-if="!mini"
|
|
10
|
+
class="ui-view-all__icon"
|
|
11
|
+
>
|
|
12
|
+
<UiIcon
|
|
13
|
+
name="arrow-right"
|
|
14
|
+
size="20"
|
|
15
|
+
/>
|
|
16
|
+
</span>
|
|
17
|
+
|
|
18
|
+
<span class="ui-view-all__content">
|
|
19
|
+
<span class="ui-view-all__text">{{ text }}</span>
|
|
20
|
+
<span
|
|
21
|
+
v-if="note"
|
|
22
|
+
class="ui-view-all__note"
|
|
23
|
+
>{{ note }}</span>
|
|
24
|
+
</span>
|
|
25
|
+
</NuxtLink>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
defineProps<{
|
|
30
|
+
to: string
|
|
31
|
+
text: string
|
|
32
|
+
/** Вторая строка: «16 models» */
|
|
33
|
+
note?: string
|
|
34
|
+
/** Круглый вариант в размер аватар-карточки — для свайпера */
|
|
35
|
+
mini?: boolean
|
|
36
|
+
}>();
|
|
37
|
+
|
|
38
|
+
const { generateLink } = useGenerateLink();
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<style lang="scss">
|
|
42
|
+
.ui-view-all {
|
|
43
|
+
display: grid;
|
|
44
|
+
place-content: center;
|
|
45
|
+
justify-items: center;
|
|
46
|
+
row-gap: 12px;
|
|
47
|
+
padding: 12px;
|
|
48
|
+
text-align: center;
|
|
49
|
+
border: 1px solid $view-all-border;
|
|
50
|
+
border-radius: $radius-xl;
|
|
51
|
+
|
|
52
|
+
/* точки поверх градиента — двумя слоями, без отдельной ноды-оверлея */
|
|
53
|
+
background-image:
|
|
54
|
+
radial-gradient(circle, $view-all-dots 1px, transparent 1px),
|
|
55
|
+
linear-gradient(to bottom right in oklab, $view-all-gradient-from, $view-all-gradient-via, $view-all-gradient-to);
|
|
56
|
+
background-size: 14px 14px, cover;
|
|
57
|
+
|
|
58
|
+
@include hover(border-color) {
|
|
59
|
+
border-color: $view-all-border--hover;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.ui-view-all__icon {
|
|
64
|
+
display: grid;
|
|
65
|
+
place-content: center;
|
|
66
|
+
width: 40px;
|
|
67
|
+
height: 40px;
|
|
68
|
+
color: $view-all-icon;
|
|
69
|
+
background-color: $view-all-icon-bg;
|
|
70
|
+
border: 1px solid $view-all-icon-border;
|
|
71
|
+
border-radius: $radius-full;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.ui-view-all__content {
|
|
75
|
+
display: grid;
|
|
76
|
+
row-gap: 2px;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.ui-view-all__text {
|
|
80
|
+
@include font-sm;
|
|
81
|
+
|
|
82
|
+
font-weight: 600;
|
|
83
|
+
color: $view-all-text;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ui-view-all__note {
|
|
87
|
+
@include font-xs;
|
|
88
|
+
|
|
89
|
+
color: $view-all-note;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.ui-view-all.--mini {
|
|
93
|
+
width: var(--card-round-size);
|
|
94
|
+
height: var(--card-round-size);
|
|
95
|
+
padding: 8px;
|
|
96
|
+
row-gap: 0;
|
|
97
|
+
border-width: 2px;
|
|
98
|
+
border-color: $card-round-border;
|
|
99
|
+
border-radius: $radius-full;
|
|
100
|
+
background-size: 12px 12px, cover;
|
|
101
|
+
|
|
102
|
+
@include hover(border-color) {
|
|
103
|
+
border-color: $card-round-border--hover;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.ui-view-all.--mini .ui-view-all__text,
|
|
108
|
+
.ui-view-all.--mini .ui-view-all__note {
|
|
109
|
+
@include font-xs;
|
|
110
|
+
|
|
111
|
+
line-height: 1.2;
|
|
112
|
+
}
|
|
113
|
+
</style>
|
package/composables/use-meta.ts
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
|
-
import { computed,
|
|
2
|
-
import type {
|
|
1
|
+
import { computed, toValue } from 'vue';
|
|
2
|
+
import type { MaybeRefOrGetter } from 'vue';
|
|
3
3
|
import { useRoute } from 'vue-router';
|
|
4
|
-
import type { INavLinkItem } from '../types';
|
|
4
|
+
import type { INavLinkItem, IPageSeo } from '../types';
|
|
5
5
|
|
|
6
|
-
type
|
|
6
|
+
type MetaText = MaybeRefOrGetter<string | undefined>;
|
|
7
|
+
|
|
8
|
+
type MetaParams = {
|
|
9
|
+
/** функция перевода (i18n) */
|
|
10
|
+
t: (key: string, params?: Record<string, unknown>) => string
|
|
11
|
+
/** ключ страницы в `meta.*` (например 'videos', 'models') */
|
|
12
|
+
pageKey: string
|
|
13
|
+
/** название бренда для подстановки в мета */
|
|
14
|
+
brandName: string
|
|
15
|
+
/** опции сортировки — задают уровень `meta.<page>.<sort>.*` в ключах */
|
|
16
|
+
sortOptions?: INavLinkItem[]
|
|
17
|
+
/** подстановка `{text}` — основной текст (slug, имя и т.д.) */
|
|
18
|
+
text?: MetaText
|
|
19
|
+
/** подстановка `{secondText}` */
|
|
20
|
+
secondText?: MetaText
|
|
21
|
+
/** подстановка `{thirdText}` */
|
|
22
|
+
thirdText?: MetaText
|
|
23
|
+
/** подстановка `{fourText}` */
|
|
24
|
+
fourthText?: MetaText
|
|
7
25
|
/**
|
|
8
26
|
* Подменяет i18n-ключ заголовка (по умолчанию 'h1') — вызывается с фактической
|
|
9
27
|
* сортировкой, потому что варианты заголовка есть не у каждой из них.
|
|
@@ -11,21 +29,19 @@ type MetaOptions = {
|
|
|
11
29
|
* висячий предлог (`… Videos in ` без страны).
|
|
12
30
|
*/
|
|
13
31
|
h1Key?: (sortType: string) => string
|
|
32
|
+
/**
|
|
33
|
+
* Мета от API (поле `seo` в ответе листинга). Каждое непустое поле перебивает
|
|
34
|
+
* свой i18n-шаблон; чего API не отдал — остаётся на i18n. Бренд бэкенд уже
|
|
35
|
+
* подставляет сам, а номер страницы — нет, поэтому суффикс пагинации
|
|
36
|
+
* дописываем здесь.
|
|
37
|
+
*/
|
|
38
|
+
seo?: MaybeRefOrGetter<IPageSeo | null | undefined>
|
|
14
39
|
};
|
|
15
40
|
|
|
16
41
|
/**
|
|
17
42
|
* Генерирует реактивные meta (title, description) и h1 с учётом сортировки, страницы и i18n-ключей.
|
|
18
|
-
* @param t - функция перевода (i18n)
|
|
19
|
-
* @param page - ключ страницы для i18n (например 'videos', 'models')
|
|
20
|
-
* @param brandName - название бренда для подстановки в мета
|
|
21
|
-
* @param sortOptions - опции сортировки (если есть)
|
|
22
|
-
* @param text - основной текст (slug, имя и т.д.)
|
|
23
|
-
* @param secondText - второй подставляемый текст
|
|
24
|
-
* @param thirdText - третий подставляемый текст
|
|
25
|
-
* @param fourthText - четвёртый подставляемый текст
|
|
26
|
-
* @param options - уточнения (подмена ключа h1)
|
|
27
43
|
*/
|
|
28
|
-
export function useMeta(t
|
|
44
|
+
export function useMeta({ t, pageKey, brandName, sortOptions, text, secondText, thirdText, fourthText, h1Key, seo }: MetaParams) {
|
|
29
45
|
const route = useRoute();
|
|
30
46
|
|
|
31
47
|
const sortType = computed(() => {
|
|
@@ -36,37 +52,60 @@ export function useMeta(t: (key: string, params?: Record<string, unknown>) => st
|
|
|
36
52
|
return queryValue && values?.includes(queryValue) ? queryValue : defaultValue;
|
|
37
53
|
});
|
|
38
54
|
|
|
55
|
+
const pageSuffix = computed(() => {
|
|
56
|
+
const pageNumber = route.query?.['page'] ? Number(route.query['page']) : 1;
|
|
57
|
+
return pageNumber === 1 ? '' : ` ${t('page')} ${pageNumber}`;
|
|
58
|
+
});
|
|
59
|
+
|
|
39
60
|
function getPath(key: string) {
|
|
40
|
-
const
|
|
41
|
-
const secondTextValue = unref(secondText);
|
|
42
|
-
const thirdTextValue = unref(thirdText);
|
|
43
|
-
const fourTextValue = unref(fourthText);
|
|
44
|
-
const pageNumber = computed(() => route.query?.['page'] ? Number(route.query['page']) : 1);
|
|
45
|
-
const pageText = unref(pageNumber) === 1 ? null : ` ${t('page')} ${unref(pageNumber)}`;
|
|
61
|
+
const pageText = pageSuffix.value;
|
|
46
62
|
|
|
47
63
|
return t(
|
|
48
|
-
`meta.${
|
|
64
|
+
`meta.${pageKey}.${sortOptions && sortOptions.length > 0 ? `${sortType.value}.` : ''}${key}`,
|
|
49
65
|
{
|
|
50
|
-
text:
|
|
51
|
-
secondText:
|
|
52
|
-
thirdText:
|
|
53
|
-
fourText:
|
|
66
|
+
text: toValue(text),
|
|
67
|
+
secondText: toValue(secondText),
|
|
68
|
+
thirdText: toValue(thirdText),
|
|
69
|
+
fourText: toValue(fourthText),
|
|
54
70
|
brandName: `| ${brandName}`, //черточка '|' нужна обязательно
|
|
55
71
|
page: pageText,
|
|
56
72
|
}
|
|
57
73
|
);
|
|
58
74
|
}
|
|
59
75
|
|
|
60
|
-
const
|
|
61
|
-
|
|
76
|
+
const apiSeo = computed(() => toValue(seo) || null);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Дописывает « Page N» так же, как это делают i18n-шаблоны — перед брендом,
|
|
80
|
+
* который бэкенд уже приклеил через `|`.
|
|
81
|
+
*/
|
|
82
|
+
function withPageSuffix(value: string) {
|
|
83
|
+
const suffix = pageSuffix.value;
|
|
84
|
+
if (!suffix) return value;
|
|
85
|
+
|
|
86
|
+
const brandIndex = value.lastIndexOf(' | ');
|
|
87
|
+
return brandIndex === -1
|
|
88
|
+
? `${value}${suffix}`
|
|
89
|
+
: `${value.slice(0, brandIndex)}${suffix}${value.slice(brandIndex)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const metaTitle = computed(() => {
|
|
93
|
+
const apiTitle = apiSeo.value?.title;
|
|
94
|
+
return apiTitle ? withPageSuffix(apiTitle) : getPath('title');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const h1 = computed(() => apiSeo.value?.h1 || getPath(h1Key?.(sortType.value) || 'h1'));
|
|
62
98
|
|
|
63
99
|
const meta = computed(() => {
|
|
64
|
-
const
|
|
100
|
+
const apiDescription = apiSeo.value?.description;
|
|
101
|
+
const description = apiDescription ? `${apiDescription}${pageSuffix.value}` : getPath('meta_description');
|
|
102
|
+
const keywords = apiSeo.value?.keywords;
|
|
65
103
|
|
|
66
104
|
return {
|
|
67
105
|
title: metaTitle.value,
|
|
68
106
|
meta: [
|
|
69
107
|
{ name: 'description', content: description },
|
|
108
|
+
...(keywords ? [{ name: 'keywords', content: keywords }] : []),
|
|
70
109
|
{ property: 'og:title', content: metaTitle.value },
|
|
71
110
|
{ property: 'og:description', content: description },
|
|
72
111
|
{ name: 'twitter:title', content: metaTitle.value },
|
package/package.json
CHANGED
package/pages/2257.vue
CHANGED
|
@@ -64,7 +64,7 @@ const { t } = useI18n();
|
|
|
64
64
|
const { generateLink } = useGenerateLink();
|
|
65
65
|
const pagePureName = useGetPureRouteName();
|
|
66
66
|
const { brandName, emails } = useAppConfig();
|
|
67
|
-
const { meta, h1 } = useMeta(t, pagePureName, brandName);
|
|
67
|
+
const { meta, h1 } = useMeta({ t, pageKey: pagePureName, brandName });
|
|
68
68
|
|
|
69
69
|
watchEffect(() => {
|
|
70
70
|
useHead(meta.value);
|
package/pages/categories/all.vue
CHANGED
|
@@ -68,7 +68,7 @@ const [
|
|
|
68
68
|
]);
|
|
69
69
|
|
|
70
70
|
const pagePureName = useGetPureRouteName();
|
|
71
|
-
const { meta, h1 } = useMeta(t, pagePureName, useAppConfig().brandName);
|
|
71
|
+
const { meta, h1 } = useMeta({ t, pageKey: pagePureName, brandName: useAppConfig().brandName });
|
|
72
72
|
|
|
73
73
|
watchEffect(() => {
|
|
74
74
|
useHead(meta.value);
|
|
@@ -69,7 +69,7 @@ const { t } = useI18n();
|
|
|
69
69
|
const { generateLink } = useGenerateLink();
|
|
70
70
|
|
|
71
71
|
const pagePureName = useGetPureRouteName();
|
|
72
|
-
const { meta, h1 } = useMeta(t, pagePureName, useAppConfig().brandName);
|
|
72
|
+
const { meta, h1 } = useMeta({ t, pageKey: pagePureName, brandName: useAppConfig().brandName });
|
|
73
73
|
|
|
74
74
|
watchEffect(() => {
|
|
75
75
|
useHead(meta.value);
|
|
@@ -59,7 +59,13 @@ const slug = computed(() => route.params[ 'slug' ] as string || '');
|
|
|
59
59
|
|
|
60
60
|
const pagePureName = useGetPureRouteName();
|
|
61
61
|
const metaTitle = computed(() => convertString().toCapitalize(slug.value));
|
|
62
|
-
const { meta, h1 } = useMeta(
|
|
62
|
+
const { meta, h1 } = useMeta({
|
|
63
|
+
t,
|
|
64
|
+
pageKey: pagePureName,
|
|
65
|
+
brandName: useAppConfig().brandName,
|
|
66
|
+
sortOptions: [],
|
|
67
|
+
text: metaTitle,
|
|
68
|
+
});
|
|
63
69
|
|
|
64
70
|
watchEffect(() => {
|
|
65
71
|
useHead(meta.value);
|
|
@@ -117,7 +117,14 @@ const routeQueryCategories = computed(() => {
|
|
|
117
117
|
|
|
118
118
|
const {
|
|
119
119
|
meta
|
|
120
|
-
} = useMeta(
|
|
120
|
+
} = useMeta({
|
|
121
|
+
t,
|
|
122
|
+
pageKey: pageName,
|
|
123
|
+
brandName: useAppConfig().brandName,
|
|
124
|
+
sortOptions: sortItemsDefault,
|
|
125
|
+
text: metaTitle,
|
|
126
|
+
secondText: routeQueryCategories,
|
|
127
|
+
});
|
|
121
128
|
|
|
122
129
|
watchEffect(() => {
|
|
123
130
|
useHead(meta.value);
|
package/pages/channels/index.vue
CHANGED
|
@@ -102,7 +102,12 @@ const items = computed(() => {
|
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
const pagePureName = useGetPureRouteName();
|
|
105
|
-
const { meta, h1 } = useMeta(
|
|
105
|
+
const { meta, h1 } = useMeta({
|
|
106
|
+
t,
|
|
107
|
+
pageKey: pagePureName,
|
|
108
|
+
brandName: useAppConfig().brandName,
|
|
109
|
+
sortOptions: sortChannels,
|
|
110
|
+
});
|
|
106
111
|
|
|
107
112
|
watchEffect(() => {
|
|
108
113
|
useHead(meta.value);
|
|
@@ -61,7 +61,7 @@ onMounted(() => {
|
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
const pagePureName = useGetPureRouteName();
|
|
64
|
-
const { meta } = useMeta(t, pagePureName, useAppConfig().brandName);
|
|
64
|
+
const { meta } = useMeta({ t, pageKey: pagePureName, brandName: useAppConfig().brandName });
|
|
65
65
|
|
|
66
66
|
watchEffect(() => {
|
|
67
67
|
useHead(meta.value);
|