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.
@@ -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
 
@@ -392,6 +392,7 @@ $layout-breakpoint: 250px;
392
392
 
393
393
  font-weight: 400;
394
394
  display: grid;
395
+ grid-row: 2;
395
396
  grid-template-columns: auto 1fr;
396
397
  align-items: center;
397
398
  column-gap: 4px;
@@ -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.838",
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
  }