itube-specs 0.0.838 → 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.
- package/assets/scss/base/_global.scss +5 -1
- package/components/card/category.vue +48 -32
- package/components/card/channel.vue +2 -1
- package/components/card/model.vue +2 -1
- package/components/card/playlist.vue +2 -1
- package/components/card/round.vue +2 -2
- package/components/card/video/index.vue +1 -0
- package/components/ui/pagination.vue +2 -12
- package/composables/__tests__/use-seo-links.test.ts +70 -9
- package/composables/use-seo-links.ts +17 -5
- package/composables/use-seo-overrides.ts +44 -0
- package/package.json +1 -1
- package/pages/categories/all.vue +5 -1
- package/pages/categories/index.vue +5 -1
- package/pages/models/index.vue +7 -1
- package/pages/videos/[slug].vue +10 -1
- package/runtime/utils/cleaners/clean-video-data.ts +1 -0
- package/types/raw/raw-video-data.d.ts +1 -1
- package/types/video-data.d.ts +2 -1
|
@@ -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
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
<div class="card-category__wrapper
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
87
|
+
box-shadow: $card-ring-shadow--hover;
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
@@ -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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
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
package/pages/categories/all.vue
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<UiSectionTitle
|
|
8
8
|
:title="h1"
|
|
9
9
|
:count="totalCount"
|
|
10
|
-
class="
|
|
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="
|
|
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;
|
package/pages/models/index.vue
CHANGED
|
@@ -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>
|
package/pages/videos/[slug].vue
CHANGED
|
@@ -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 || '');
|
|
@@ -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;
|
|
36
|
+
is_canonical: boolean;
|
|
37
37
|
custom_thumb_md5: string; // unused
|
|
38
38
|
channel: {
|
|
39
39
|
name: string
|