itube-specs 0.0.837 → 0.0.840

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.
@@ -28,12 +28,16 @@ html {
28
28
 
29
29
  body {
30
30
  font-family: $default-font-family, system-ui, sans-serif;
31
- background-color: $body-bg;
32
31
  color: $body-text;
32
+ background-color: $body-bg;
33
33
  -webkit-font-smoothing: antialiased;
34
34
  -moz-osx-font-smoothing: grayscale;
35
35
  }
36
36
 
37
+ main {
38
+ background-color: $main-bg;
39
+ }
40
+
37
41
  a {
38
42
  color: $link-text;
39
43
  text-decoration: none;
@@ -5,38 +5,40 @@
5
5
  :to="localePath(`/categories/${convertString().toSlug(slug)}`)"
6
6
  class="card-category"
7
7
  >
8
- <UiImg
9
- :alt="card.title"
10
- :src="currentImage"
11
- :loading="priority ? 'eager' : 'lazy'"
12
- :priority="priority"
13
- class="card-category__img"
14
- sizes="320px"
15
- width="320"
16
- height="180"
17
- @error="onImageError"
18
- />
19
- <div class="card-category__wrapper">
20
- <div class="card-category__wrapper-header">
21
- <span class="card-category__title">
22
- {{ card.title }}
23
- <img
24
- v-if="card.icon"
25
- :src="`/icons/flags/${card.icon}.svg`"
26
- :alt="card.icon"
27
- class="card-category__icon"
28
- width="17"
29
- height="17"
30
- loading="lazy"
31
- >
32
- </span>
33
- <span class="card-category__count">
34
- <UiIcon
35
- name="video-camera"
36
- size="14"
37
- />
38
- {{ $n(card.videosCount, 'compact') }}
39
- </span>
8
+ <div class="card-category__inner">
9
+ <UiImg
10
+ :alt="card.title"
11
+ :src="currentImage"
12
+ :loading="priority ? 'eager' : 'lazy'"
13
+ :priority="priority"
14
+ class="card-category__img"
15
+ sizes="320px"
16
+ width="320"
17
+ height="180"
18
+ @error="onImageError"
19
+ />
20
+ <div class="card-category__wrapper">
21
+ <div class="card-category__wrapper-header">
22
+ <span class="card-category__title">
23
+ {{ card.title }}
24
+ <img
25
+ v-if="card.icon"
26
+ :src="`/icons/flags/${card.icon}.svg`"
27
+ :alt="card.icon"
28
+ class="card-category__icon"
29
+ width="17"
30
+ height="17"
31
+ loading="lazy"
32
+ >
33
+ </span>
34
+ <span class="card-category__count">
35
+ <UiIcon
36
+ name="video-camera"
37
+ size="14"
38
+ />
39
+ {{ $n(card.videosCount, 'compact') }}
40
+ </span>
41
+ </div>
40
42
  </div>
41
43
  </div>
42
44
  </NuxtLink>
@@ -73,6 +75,19 @@ const slug = computed(() => {
73
75
 
74
76
  <style lang="scss">
75
77
  .card-category {
78
+ padding: 2px;
79
+ border-radius: $radius-xl;
80
+
81
+ @include from-br(sm) {
82
+ transition: box-shadow var(--transition);
83
+
84
+ &:is(:hover, :focus-visible) {
85
+ box-shadow: $card-ring-shadow--hover;
86
+ }
87
+ }
88
+ }
89
+
90
+ .card-category__inner {
76
91
  border-radius: $radius;
77
92
  border: 1px solid $category-card-border;
78
93
  background-color: $category-card-bg;
@@ -93,6 +108,7 @@ const slug = computed(() => {
93
108
  overflow: hidden;
94
109
  white-space: nowrap;
95
110
  text-overflow: ellipsis;
111
+ color: $category-card-title;
96
112
 
97
113
  @include from-br(sm) {
98
114
  transition: var(--transition) color;
@@ -116,8 +116,9 @@ const infoItems = computed<InfoItem[]>(() => [
116
116
  border: 1px solid $channel-card-border;
117
117
  background-color: $channel-card-bg;
118
118
 
119
- @include hover(border-color) {
119
+ @include hover(border-color, box-shadow) {
120
120
  border-color: $channel-card-border--hover;
121
+ box-shadow: $card-ring-shadow--hover;
121
122
  }
122
123
  }
123
124
 
@@ -118,9 +118,10 @@ function getValue(item: { title?: string; value?: string }) {
118
118
  border: 1px solid $model-card-border;
119
119
  display: block;
120
120
 
121
- @include hover(border-color, background-color) {
121
+ @include hover(border-color, background-color, box-shadow) {
122
122
  border-color: $model-card-border--hover;
123
123
  background-color: $model-card-bg--hover;
124
+ box-shadow: $card-ring-shadow--hover;
124
125
  }
125
126
  }
126
127
 
@@ -231,8 +231,9 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
231
231
  background-color: $playlist-card-bg;
232
232
  border: 1px solid $playlist-card-border;
233
233
 
234
- @include hover(border-color) {
234
+ @include hover(border-color, box-shadow) {
235
235
  border-color: $playlist-card-border--hover;
236
+ box-shadow: $card-ring-shadow--hover;
236
237
  }
237
238
  }
238
239
 
@@ -77,14 +77,14 @@ function onImageError() {
77
77
  border: 2px solid $card-round-border;
78
78
 
79
79
  @include from-br(sm) {
80
- transition: var(--transition) border-color;
80
+ transition: var(--transition) border-color, var(--transition) box-shadow;
81
81
  }
82
82
  }
83
83
 
84
84
  .card-round:is(:hover, :focus-visible) .card-round__img {
85
85
  @include from-br(sm) {
86
86
  border-color: $card-round-border--hover;
87
- outline: 1px solid $card-round-outline--hover;
87
+ box-shadow: $card-ring-shadow--hover;
88
88
  }
89
89
  }
90
90
 
@@ -100,6 +100,7 @@ const props = defineProps<{
100
100
  showShare?: boolean
101
101
  showReport?: boolean
102
102
  showRemove?: boolean
103
+ autoOpen?: boolean
103
104
  }>();
104
105
 
105
106
  const emit = defineEmits<{
@@ -117,6 +118,10 @@ function openDropdown() {
117
118
  dropdownRef.value?.openDropdown();
118
119
  }
119
120
 
121
+ onMounted(() => {
122
+ if (props.autoOpen) openDropdown();
123
+ });
124
+
120
125
  function onAddPlaylist() {
121
126
  usePlaylistAdd().openPlaylistAdd(isAuthorized, props.card, openDropdown);
122
127
  }
@@ -146,16 +151,4 @@ defineExpose({ openDropdown });
146
151
  padding: 16px 32px 16px 16px;
147
152
  border-bottom: 1px solid $video-card-dropdown-border;
148
153
  }
149
-
150
- .card-video-dropdown__trigger > .ui-icon {
151
- @include from-br(sm) {
152
- transition: var(--transition) transform;
153
- }
154
- }
155
-
156
- .card-video-dropdown__trigger:is(:hover, :focus-visible) > .ui-icon {
157
- @include from-br(sm) {
158
- transform: scale($video-card-dropdown-size--hover);
159
- }
160
- }
161
154
  </style>
@@ -112,11 +112,25 @@
112
112
  </div>
113
113
  </div>
114
114
  <LazyCardVideoDropdown
115
+ v-if="dropdownMounted"
115
116
  :card="card"
116
117
  :show-watch-later="featureFlags.FeatureWatchLaterEnabled"
117
118
  class="card-video__dropdown"
118
119
  show-report
120
+ auto-open
119
121
  />
122
+ <button
123
+ v-else
124
+ :aria-label="t('open_menu')"
125
+ class="card-video__dropdown card-video-dropdown__trigger"
126
+ type="button"
127
+ @click="dropdownMounted = true"
128
+ >
129
+ <UiIcon
130
+ name="dots-vertical"
131
+ size="18"
132
+ />
133
+ </button>
120
134
  </div>
121
135
  </div>
122
136
  </template>
@@ -151,6 +165,8 @@ const isChannelPage = computed(() => pageName === 'channels-channel');
151
165
 
152
166
  const isPreviewShow = ref(false);
153
167
 
168
+ const dropdownMounted = ref(false);
169
+
154
170
  function playVideo() {
155
171
  isPreviewShow.value = true;
156
172
  }
@@ -252,6 +268,18 @@ $layout-breakpoint: 250px;
252
268
  }
253
269
  }
254
270
 
271
+ .card-video-dropdown__trigger > .ui-icon {
272
+ @include from-br(sm) {
273
+ transition: var(--transition) transform;
274
+ }
275
+ }
276
+
277
+ .card-video-dropdown__trigger:is(:hover, :focus-visible) > .ui-icon {
278
+ @include from-br(sm) {
279
+ transform: scale($video-card-dropdown-size--hover);
280
+ }
281
+ }
282
+
255
283
  .card-video__resolution {
256
284
  top: $inset-label;
257
285
  right: $inset-label;
@@ -364,6 +392,7 @@ $layout-breakpoint: 250px;
364
392
 
365
393
  font-weight: 400;
366
394
  display: grid;
395
+ grid-row: 2;
367
396
  grid-template-columns: auto 1fr;
368
397
  align-items: center;
369
398
  column-gap: 4px;
@@ -47,12 +47,6 @@ const mobileCount = computed(() => props.count > 9 ? '9+' : props.count);
47
47
  }
48
48
  }
49
49
 
50
- .filter-btn__reset {
51
- @include from-br(sm) {
52
- color: $filter-button-reset;
53
- }
54
- }
55
-
56
50
  .filter-btn__count {
57
51
  @include font-xs;
58
52
 
@@ -126,7 +126,6 @@
126
126
  </template>
127
127
 
128
128
  <script setup lang="ts">
129
- // не забыват проставлять key на компоненте для переинициализации и отслеживания props.total чтобы пересчитывалось правильное сео
130
129
  import type { ButtonThemes } from '../../types';
131
130
  import { getPaginationNumbers } from '../../utils/ui-pagination/get-pagination-numbers';
132
131
  import { getPaginationLink } from '../../utils/ui-pagination/get-pagination-link';
@@ -180,17 +179,8 @@ watch(() => route.query['page'], () => {
180
179
  document.documentElement.querySelector('#anchor')?.scrollIntoView({ behavior: 'smooth' });
181
180
  });
182
181
 
183
- const i18n = useI18n();
184
- const localePath = useLocalePath();
185
- const siteUrl = useAppConfig().siteUrl as string;
186
- const { canonicalUrl, alternateLinks } = useSeoLinks(siteUrl, props.total === 1, i18n, localePath);
187
-
188
- useHead({
189
- link: computed(() => [
190
- { rel: 'canonical', href: canonicalUrl.value, key: `canonical-${route.path}` },
191
- ...alternateLinks.value,
192
- ]),
193
- });
182
+ // canonical/hreflang выпускает app.vue — отсюда уходит только факт, который знает лишь пагинация
183
+ useSetSeoOverride(() => ({ alonePage: props.total === 1 }));
194
184
  </script>
195
185
 
196
186
  <style lang="scss">
@@ -2,6 +2,7 @@
2
2
  import { describe, it, expect, beforeEach } from 'vitest';
3
3
  import { mockNuxtImport } from '@nuxt/test-utils/runtime';
4
4
  import { useSeoLinks } from '../use-seo-links';
5
+ import { useSeoOverridesState } from '../use-seo-overrides';
5
6
 
6
7
  let mockRoute = { path: '/videos', query: {} as Record<string, any> };
7
8
 
@@ -12,36 +13,47 @@ const mockI18n = {
12
13
  locales: { value: ['en', 'fr', 'de', 'ja', 'zh'] },
13
14
  };
14
15
 
15
- const mockLocalePath = (path: string, locale: string) =>
16
- locale === 'en' ? path : `/${locale}${path}`;
16
+ const mockLocalePath = (path: string, locale: string) => {
17
+ const bare = path.replace(/^\/(?:it|fr|es|ja|de|ko|ru|zh)(?=\/|$)/, '');
18
+ return locale === 'en' ? bare : `/${locale}${bare}`;
19
+ };
17
20
 
18
21
  describe('useSeoLinks', () => {
19
22
  beforeEach(() => {
20
23
  mockRoute = { path: '/videos', query: {} };
21
24
  mockI18n.locale.value = 'en';
25
+ useSeoOverridesState().value = null;
22
26
  });
23
27
 
24
28
  it('canonical URL', () => {
25
- const { canonicalUrl } = useSeoLinks('https://example.com', false, mockI18n, mockLocalePath);
29
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
26
30
  expect(canonicalUrl.value).toBe('https://example.com/videos');
27
31
  });
28
32
 
29
33
  it('canonical с query params', () => {
30
34
  mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
31
- const { canonicalUrl } = useSeoLinks('https://example.com', false, mockI18n, mockLocalePath);
35
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
32
36
  expect(canonicalUrl.value).toContain('page=2');
33
37
  expect(canonicalUrl.value).toContain('sort=popular');
34
38
  });
35
39
 
36
40
  it('исключает sort при alonePage', () => {
37
41
  mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
38
- const { canonicalUrl } = useSeoLinks('https://example.com', true, mockI18n, mockLocalePath);
42
+ useSeoOverridesState().value = { path: '/videos', alonePage: true };
43
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
39
44
  expect(canonicalUrl.value).toContain('page=2');
40
45
  expect(canonicalUrl.value).not.toContain('sort');
41
46
  });
42
47
 
48
+ it('alonePage с чужого пути не применяется', () => {
49
+ mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
50
+ useSeoOverridesState().value = { path: '/models', alonePage: true };
51
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
52
+ expect(canonicalUrl.value).toContain('sort=popular');
53
+ });
54
+
43
55
  it('alternate links для всех локалей', () => {
44
- const { alternateLinks } = useSeoLinks('https://example.com', false, mockI18n, mockLocalePath);
56
+ const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
45
57
  const hreflangs = alternateLinks.value.map(l => l.hreflang);
46
58
  expect(hreflangs).toContain('en');
47
59
  expect(hreflangs).toContain('fr');
@@ -52,21 +64,70 @@ describe('useSeoLinks', () => {
52
64
  });
53
65
 
54
66
  it('ja маппится в ja-JP', () => {
55
- const { alternateLinks } = useSeoLinks('https://example.com', false, mockI18n, mockLocalePath);
67
+ const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
56
68
  const jaLink = alternateLinks.value.find(l => l.hreflang === 'ja-JP');
57
69
  expect(jaLink).toBeDefined();
58
70
  expect(jaLink!.href).toContain('/ja/videos');
59
71
  });
60
72
 
61
73
  it('x-default указывает на en', () => {
62
- const { alternateLinks } = useSeoLinks('https://example.com', false, mockI18n, mockLocalePath);
74
+ const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
63
75
  const xDefault = alternateLinks.value.find(l => l.hreflang === 'x-default');
64
76
  expect(xDefault!.href).toBe('https://example.com/videos');
65
77
  });
66
78
 
79
+ it('canonicalLocale переводит canonical на указанную локаль', () => {
80
+ mockRoute = { path: '/ja/videos/some-video', query: {} };
81
+ mockI18n.locale.value = 'ja';
82
+ useSeoOverridesState().value = { path: '/ja/videos/some-video', canonicalLocale: 'en' };
83
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
84
+ expect(canonicalUrl.value).toBe('https://example.com/videos/some-video');
85
+ });
86
+
87
+ it('canonicalLocale сохраняет допустимые query params', () => {
88
+ mockRoute = { path: '/ja/videos/some-video', query: { page: '2', foo: 'bar' } };
89
+ mockI18n.locale.value = 'ja';
90
+ useSeoOverridesState().value = { path: '/ja/videos/some-video', canonicalLocale: 'en' };
91
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
92
+ expect(canonicalUrl.value).toContain('/videos/some-video');
93
+ expect(canonicalUrl.value).not.toContain('/ja/');
94
+ expect(canonicalUrl.value).toContain('page=2');
95
+ expect(canonicalUrl.value).not.toContain('foo');
96
+ });
97
+
98
+ it('canonicalLocale убирает hreflang-альтернативы', () => {
99
+ mockRoute = { path: '/ja/videos/some-video', query: {} };
100
+ mockI18n.locale.value = 'ja';
101
+ useSeoOverridesState().value = { path: '/ja/videos/some-video', canonicalLocale: 'en' };
102
+ const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
103
+ expect(alternateLinks.value).toEqual([]);
104
+ });
105
+
106
+ it('canonicalLocale игнорируется для чужого пути', () => {
107
+ mockRoute = { path: '/ja/videos/other-video', query: {} };
108
+ mockI18n.locale.value = 'ja';
109
+ useSeoOverridesState().value = { path: '/ja/videos/some-video', canonicalLocale: 'en' };
110
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
111
+ expect(canonicalUrl.value).toBe('https://example.com/ja/videos/other-video');
112
+ });
113
+
114
+ it('canonicalLocale игнорируется, если совпадает с текущей локалью', () => {
115
+ mockRoute = { path: '/videos/some-video', query: {} };
116
+ useSeoOverridesState().value = { path: '/videos/some-video', canonicalLocale: 'en' };
117
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
118
+ expect(canonicalUrl.value).toBe('https://example.com/videos/some-video');
119
+ });
120
+
121
+ it('без override canonical остаётся на текущей локали', () => {
122
+ mockRoute = { path: '/ja/videos/some-video', query: {} };
123
+ mockI18n.locale.value = 'ja';
124
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
125
+ expect(canonicalUrl.value).toBe('https://example.com/ja/videos/some-video');
126
+ });
127
+
67
128
  it('игнорирует неразрешённые query params', () => {
68
129
  mockRoute = { path: '/videos', query: { page: '1', foo: 'bar', categories: 'teen' } };
69
- const { canonicalUrl } = useSeoLinks('https://example.com', false, mockI18n, mockLocalePath);
130
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
70
131
  expect(canonicalUrl.value).toContain('page=1');
71
132
  expect(canonicalUrl.value).toContain('categories=teen');
72
133
  expect(canonicalUrl.value).not.toContain('foo');
@@ -7,21 +7,22 @@ type LocalePathMock = (path: string, localeCode: string) => string;
7
7
 
8
8
  /**
9
9
  * Генерирует canonical URL и hreflang alternate-ссылки для всех локалей с сохранением допустимых query-параметров.
10
+ * Уточнения (единственная страница пагинации, подмена локали canonical) приходят через useSeoOverrides.
10
11
  * @param baseDomain - базовый домен (например 'https://example.com')
11
- * @param alonePage - если true, параметр sort исключается из URL
12
12
  * @param i18n - объект i18n с locale и locales
13
13
  * @param localePath - функция для генерации пути с локалью
14
14
  */
15
- export const useSeoLinks = (baseDomain: string, alonePage: boolean = false, i18n: I18nMock, localePath: LocalePathMock) => {
15
+ export const useSeoLinks = (baseDomain: string, i18n: I18nMock, localePath: LocalePathMock) => {
16
16
  const route = useRoute();
17
+ const overrides = useSeoOverrides();
17
18
 
18
19
  const allowedParams: string[] = ['page', 'sort', 'categories'];
19
20
  const createUrlWithParams = (path: string) => {
20
21
  const url = new URL(`${baseDomain}${path}`);
21
22
 
22
23
  Object.entries(route.query).forEach(([key, value]) => {
23
- // Если alonePage === true, пропускаем sort
24
- if (allowedParams.includes(key) && !(alonePage && key === 'sort')) {
24
+ // на единственной странице sort не участвует в URL
25
+ if (allowedParams.includes(key) && !(overrides.value.alonePage && key === 'sort')) {
25
26
  if (Array.isArray(value)) {
26
27
  value.forEach(v => url.searchParams.append(key, v));
27
28
  }
@@ -41,9 +42,20 @@ export const useSeoLinks = (baseDomain: string, alonePage: boolean = false, i18n
41
42
 
42
43
  const mapHreflang = (code: string) => langMap[code] || code;
43
44
 
44
- const canonicalUrl = computed(() => createUrlWithParams(route.path));
45
+ const forcedCanonicalLocale = computed(() => {
46
+ const { canonicalLocale } = overrides.value;
47
+
48
+ return canonicalLocale && canonicalLocale !== i18n.locale.value ? canonicalLocale : null;
49
+ });
50
+
51
+ const canonicalUrl = computed(() => createUrlWithParams(
52
+ forcedCanonicalLocale.value ? localePath(route.path, forcedCanonicalLocale.value) : route.path
53
+ ));
45
54
 
46
55
  const alternateLinks = computed(() => {
56
+ // страница неканонична (нет перевода) — hreflang-кластер объявляет канонический URL, а не эта страница
57
+ if (forcedCanonicalLocale.value) return [];
58
+
47
59
  const links = [];
48
60
  const currentLocaleCode = i18n.locale.value;
49
61
 
@@ -0,0 +1,44 @@
1
+ type SeoOverrides = {
2
+ /** локаль, на которую должен вести canonical вместо текущей (у контента нет перевода) */
3
+ canonicalLocale?: string;
4
+ /** страница единственная в пагинации — из canonical выкидывается sort */
5
+ alonePage?: boolean;
6
+ };
7
+
8
+ type SeoOverridesState = SeoOverrides & {
9
+ path: string;
10
+ };
11
+
12
+ /**
13
+ * Уточнения к SEO-ссылкам, которые знает только страница или конкретный компонент,
14
+ * а выпускает теги один app.vue. Привязаны к route.path, чтобы значение
15
+ * не «протекало» на следующую страницу при клиентской навигации.
16
+ */
17
+ export const useSeoOverridesState = () => useState<SeoOverridesState | null>('seo-overrides', () => null);
18
+
19
+ /**
20
+ * Текущие уточнения для активного маршрута.
21
+ */
22
+ export const useSeoOverrides = () => {
23
+ const route = useRoute();
24
+ const state = useSeoOverridesState();
25
+
26
+ return computed<SeoOverrides>(() => state.value?.path === route.path ? state.value : {});
27
+ };
28
+
29
+ /**
30
+ * Записывает уточнение для текущего маршрута. Значения от разных источников
31
+ * (страница, пагинация) складываются; при смене маршрута состояние сбрасывается.
32
+ */
33
+ export const useSetSeoOverride = (overrides: MaybeRefOrGetter<SeoOverrides>) => {
34
+ const route = useRoute();
35
+ const state = useSeoOverridesState();
36
+
37
+ watchEffect(() => {
38
+ const patch = toValue(overrides);
39
+
40
+ state.value = state.value?.path === route.path
41
+ ? { ...state.value, ...patch }
42
+ : { path: route.path, ...patch };
43
+ });
44
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.837",
4
+ "version": "0.0.840",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -7,7 +7,7 @@
7
7
  <UiSectionTitle
8
8
  :title="h1"
9
9
  :count="totalCount"
10
- class="_title"
10
+ class="all__title"
11
11
  icon="grid"
12
12
  />
13
13
 
@@ -97,4 +97,8 @@ const breadcrumbs = [
97
97
  .all__popular-models {
98
98
  margin-top: 32px;
99
99
  }
100
+
101
+ .all__title {
102
+ margin-bottom: 16px;
103
+ }
100
104
  </style>
@@ -8,7 +8,7 @@
8
8
  <UiSectionTitle
9
9
  :title="h1"
10
10
  :count="totalCount"
11
- class="all__title _title"
11
+ class="grouped__title"
12
12
  icon="grid"
13
13
  />
14
14
 
@@ -100,6 +100,10 @@ const breadcrumbs = [
100
100
  }
101
101
  }
102
102
 
103
+ .grouped__title {
104
+ margin-bottom: 16px;
105
+ }
106
+
103
107
  .grouped__popular-models {
104
108
  display: flex;
105
109
  column-gap: 4px;
@@ -18,7 +18,7 @@
18
18
 
19
19
  <SectionSeoText
20
20
  :text="t('models_footer_text')"
21
- class="_container"
21
+ class="models__seo _container"
22
22
  links-link="models_footer_links"
23
23
  />
24
24
  </div>
@@ -85,3 +85,9 @@ const breadcrumbs = [
85
85
  },
86
86
  ] as IBreadcrumbItem[];
87
87
  </script>
88
+
89
+ <style lang="scss">
90
+ .models__seo {
91
+ margin-bottom: 24px;
92
+ }
93
+ </style>
@@ -46,7 +46,16 @@ const [
46
46
  useFetchTopModels(ModelsApiService.getTop, 24, AsyncData.TopModelsMore, String(pageName)),
47
47
  ]);
48
48
 
49
- const { t } = useI18n();
49
+ const { t, locale } = useI18n();
50
+
51
+ const defaultLocale = useRuntimeConfig().public.locales.default;
52
+
53
+ // у видео нет перевода на текущий язык — canonical ведёт на дефолтную локаль
54
+ useSetSeoOverride(() => ({
55
+ canonicalLocale: videoData.value && !videoData.value.isCanonical && locale.value !== defaultLocale
56
+ ? defaultLocale
57
+ : undefined,
58
+ }));
50
59
 
51
60
  const title = computed(() => videoData.value?.title || '');
52
61
  const description = computed(() => videoData.value?.description || '');
@@ -67,4 +67,5 @@ export const cleanVideoData = (data: IRawVideoData): IVideoData => ({
67
67
  dislikes: data.dislikes || 0,
68
68
  hotspots: cleanHotspots(data.hotspots),
69
69
  timelinesGroups: cleanTimelineGroups(data.timelines_groups),
70
+ isCanonical: data.is_canonical || false,
70
71
  });
@@ -33,7 +33,7 @@ export interface IRawVideoData {
33
33
  is_edited: boolean; // unused
34
34
  fake_user_name: string; // unused
35
35
  referer_phrases: null; // unused
36
- is_canonical: boolean; // unused
36
+ is_canonical: boolean;
37
37
  custom_thumb_md5: string; // unused
38
38
  channel: {
39
39
  name: string
@@ -62,5 +62,6 @@ export interface IVideoData {
62
62
  thumbNum: number;
63
63
  description: string;
64
64
  hotspots: number[] | null,
65
- timelinesGroups: ISceneGroup[] | null
65
+ timelinesGroups: ISceneGroup[] | null,
66
+ isCanonical: boolean
66
67
  }