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.
Files changed (66) hide show
  1. package/assets/icons/common/bell.svg +1 -0
  2. package/assets/scss/mixins/_font-sizes.scss +5 -0
  3. package/assets/scss/themes/dark/_theme.scss +38 -23
  4. package/assets/scss/themes/light/_theme.scss +34 -21
  5. package/assets/scss/vars/_palette.scss +1 -0
  6. package/assets/scss/vars/_tokens.scss +2 -0
  7. package/components/card/category.vue +22 -1
  8. package/components/card/playlist.vue +32 -4
  9. package/components/card/round.vue +48 -4
  10. package/components/filter/main.vue +2 -6
  11. package/components/filter/model.vue +2 -6
  12. package/components/grid/playlists.vue +2 -0
  13. package/components/layout/burger-item.vue +8 -8
  14. package/components/layout/extra-links.vue +2 -0
  15. package/components/layout/header.vue +29 -3
  16. package/components/layout/logo.vue +11 -2
  17. package/components/layout/notifications.vue +79 -0
  18. package/components/section/swiper-cards.vue +65 -34
  19. package/components/ui/btn.vue +15 -0
  20. package/components/ui/popup.vue +0 -4
  21. package/components/ui/swiper.vue +19 -4
  22. package/components/ui/view-all.vue +113 -0
  23. package/composables/use-meta.ts +67 -28
  24. package/package.json +1 -1
  25. package/pages/2257.vue +1 -1
  26. package/pages/categories/all.vue +1 -1
  27. package/pages/categories/index.vue +1 -1
  28. package/pages/categories/letter/[slug].vue +7 -1
  29. package/pages/channels/[channel].vue +8 -1
  30. package/pages/channels/index.vue +6 -1
  31. package/pages/confirm/registration/[token].vue +1 -1
  32. package/pages/contacts.vue +1 -1
  33. package/pages/dmca.vue +1 -1
  34. package/pages/following.vue +201 -0
  35. package/pages/index.vue +8 -10
  36. package/pages/library/index.vue +5 -2
  37. package/pages/models/[slug]/channel/[channel].vue +9 -1
  38. package/pages/models/[slug]/index.vue +9 -1
  39. package/pages/models/[slug]/tag/[tag].vue +10 -1
  40. package/pages/models/index.vue +7 -1
  41. package/pages/models/letter/[slug].vue +8 -1
  42. package/pages/playlists/[playlistId]/[slug].vue +9 -1
  43. package/pages/playlists/index.vue +6 -1
  44. package/pages/policy.vue +1 -1
  45. package/pages/search/[slug]/index.vue +26 -38
  46. package/pages/search/[slug]/models.vue +7 -1
  47. package/pages/search/[slug]/videos.vue +7 -1
  48. package/pages/tag/[tag].vue +8 -1
  49. package/pages/terms.vue +1 -1
  50. package/pages/user/profile.vue +1 -1
  51. package/pages/user/recovery-password.vue +1 -1
  52. package/pages/user/settings.vue +1 -1
  53. package/pages/videos/[slug].vue +8 -1
  54. package/runtime/utils/cleaners/__tests__/clean-model-info.test.ts +13 -0
  55. package/runtime/utils/cleaners/clean-model-info.ts +1 -0
  56. package/server/api/models/list-by-niche.post.ts +3 -1
  57. package/stubs/library-follows.ts +75 -2
  58. package/types/button-themes.d.ts +1 -1
  59. package/types/card-info.d.ts +2 -0
  60. package/types/category-card.d.ts +2 -0
  61. package/types/index.d.ts +1 -0
  62. package/types/page-seo.d.ts +6 -0
  63. package/types/paginated-response.d.ts +3 -0
  64. package/types/playlist-card.d.ts +2 -0
  65. package/types/raw/raw-model-info.d.ts +2 -1
  66. 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
- <UiSectionTitle
5
- :title="title || t('top_models')"
6
- :count="items.length"
7
- class="section-swiper-cards__title"
8
- icon="user"
9
- title-tag="h2"
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
- <NuxtLink
28
- :to="generateLink(allLink ?? '/')"
29
- class="section-swiper-cards__all"
30
- >{{ allLinkText }}
31
- </NuxtLink>
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
- margin-bottom: 16px;
65
- text-transform: uppercase;
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
- .section-swiper-cards__all {
78
- @include font-sm;
79
-
80
- display: grid;
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
- @include hover(border-color, background-color) {
93
- border-color: $card-round-border--hover;
94
- background-color: $card-round-all-bg--hover;
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>
@@ -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>
@@ -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 {
@@ -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: 0;
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 * -1;
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>
@@ -1,9 +1,27 @@
1
- import { computed, unref } from 'vue';
2
- import type { Ref } from 'vue';
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 MetaOptions = {
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: (key: string, params?: Record<string, unknown>) => string, page: string, brandName: string, sortOptions?: INavLinkItem[], text?: Ref<string>, secondText?: Ref<string>, thirdText?: Ref<string>, fourthText?: Ref<string>, options?: MetaOptions) {
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 plainSlug = unref(text);
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.${page}.${sortOptions && sortOptions.length > 0 ? `${sortType.value}.` : ''}${key}`,
64
+ `meta.${pageKey}.${sortOptions && sortOptions.length > 0 ? `${sortType.value}.` : ''}${key}`,
49
65
  {
50
- text: plainSlug,
51
- secondText: secondTextValue,
52
- thirdText: thirdTextValue,
53
- fourText: fourTextValue,
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 metaTitle = computed(() => getPath('title'));
61
- const h1 = computed(() => getPath(options?.h1Key?.(sortType.value) || 'h1'));
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 description = getPath('meta_description');
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.896",
4
+ "version": "0.0.898",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
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);
@@ -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(t, pagePureName, useAppConfig().brandName, [], metaTitle);
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(t, pageName, useAppConfig().brandName, sortItemsDefault, metaTitle, routeQueryCategories);
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);
@@ -102,7 +102,12 @@ const items = computed(() => {
102
102
  });
103
103
 
104
104
  const pagePureName = useGetPureRouteName();
105
- const { meta, h1 } = useMeta(t, pagePureName, useAppConfig().brandName, sortChannels);
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);