itube-specs 0.0.1040 → 0.0.1042
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/SITE_UPDATES.md +14 -0
- package/app.config.ts +43 -0
- package/assets/scss/layout/_layout.scss +1 -0
- package/assets/scss/mixins/_page.scss +1 -0
- package/components/card/playlist.vue +3 -0
- package/components/ui/breadcrumbs.vue +8 -0
- package/composables/page/use-category-page.ts +43 -13
- package/composables/page/use-search-page.ts +8 -4
- package/composables/use-meta.ts +60 -22
- package/package.json +2 -1
- package/pages/videos/[slug].vue +24 -1
package/SITE_UPDATES.md
CHANGED
|
@@ -11,6 +11,20 @@
|
|
|
11
11
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
12
12
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
13
13
|
|
|
14
|
+
## 0.0.1041 — 2026-10-19
|
|
15
|
+
|
|
16
|
+
- **Сайтам со своим `composables/use-meta.ts` или форком `pages/videos/[slug].vue` ради меты (videosection-redesign):**
|
|
17
|
+
правила меты videosection.com переехали в слой. `useMeta`: для страницы > 1 сначала ищется ключ `<key>_paged` (с
|
|
18
|
+
`{p}` — номером), мета из API (`seo`) может идти через шаблоны `api_title` / `api_description` (+ `_paged`) с
|
|
19
|
+
`{text}` = значение API; нет таких ключей — всё как раньше. `useCategoryPage(pageData, { metaKeys: true })` — мета и
|
|
20
|
+
h1 категории через `useMeta` по ключам (`{text}` — название, анти-дубль слова ниши), мета категории из API — через
|
|
21
|
+
`api_*`. `useSearchPage(relatedSearch, { metaSortOptions })` — уровень сортировки в ключах меты поиска.
|
|
22
|
+
`appConfig.videoPage.metaFromPhrases` — description ролика из фраз перехода / тегов / категорий (склейка ключами
|
|
23
|
+
`meta.videos-slug.tags_and` / `tags_tail`), `{thirdText}` — название в Title Case. Свой `use-meta.ts` и форк страницы
|
|
24
|
+
видео удалить (образец — коммит videosection-redesign с этим изменением). Не сделать — копии продолжат работать,
|
|
25
|
+
но будут отставать от слоя (в форке страницы видео, например, остаётся пустой «Recent searches»). Остальным —
|
|
26
|
+
ничего, по умолчанию мета как была.
|
|
27
|
+
|
|
14
28
|
## 0.0.1040 — 2026-10-19
|
|
15
29
|
|
|
16
30
|
- **Сайтам со своими `utils/adverts-helper.ts` / `utils/popunder-helper.ts` (videosection-redesign):** не копировать
|
package/app.config.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { INavExtraItem, INavItemOverride, IQuickFilterScheme, ISecondNavItem } from './types';
|
|
2
|
+
|
|
3
|
+
export default defineAppConfig({
|
|
4
|
+
// Дефолты, чтобы слой собирался/тестировался standalone.
|
|
5
|
+
// Проект deep-merge'ит свои per-site значения поверх.
|
|
6
|
+
theme: 'dark',
|
|
7
|
+
quickVideoFilters: [] as IQuickFilterScheme[],
|
|
8
|
+
logoSize: null as { width: number, height: number } | null,
|
|
9
|
+
secondNav: [] as ISecondNavItem[],
|
|
10
|
+
/** Правки пунктов главной навигации слоя по их `title` (подпись, ссылка по нишам). */
|
|
11
|
+
navOverrides: {} as Record<string, INavItemOverride>,
|
|
12
|
+
/** Дополнительные пункты главной навигации, каждый — за пунктом слоя `after`. */
|
|
13
|
+
navExtraItems: [] as INavExtraItem[],
|
|
14
|
+
/** Есть ли у сайта сгруппированный вид категорий (`/categories` — группы, `/categories/all` — A–Z).
|
|
15
|
+
* `false` — `/categories` сам список A–Z: `CategoryNav` без табов Grouped / A-Z, «All» ведёт на `/categories`. */
|
|
16
|
+
categoriesGrouped: true,
|
|
17
|
+
/** Блок Live Cams в сетках листингов (при `FeatureWebcamsEnabled`), `null` — нет. `videos` — после какой строки
|
|
18
|
+
* карточек и сколько врезок вебкам-тумб (хвост того же списка) в сетке видео листинга (не похожие, не плейлист,
|
|
19
|
+
* не когда страница передала врезки сама); `models` — в основной сетке моделей (`priority`, не футер). */
|
|
20
|
+
gridWebcams: {
|
|
21
|
+
videos: null as { afterRows: number, inserts: number } | null,
|
|
22
|
+
models: null as { afterRows: number } | null,
|
|
23
|
+
},
|
|
24
|
+
/** Класс кликабельной части карточек в сетках видео и моделей — для сайтовых хуков (триггер попандера). */
|
|
25
|
+
cardTriggerClass: '',
|
|
26
|
+
/** Состав страницы видео. */
|
|
27
|
+
videoPage: {
|
|
28
|
+
/** Фразы, по которым пришли на ролик, — строкой над заголовком. */
|
|
29
|
+
refererPhrases: false,
|
|
30
|
+
/** Пагинация похожих. */
|
|
31
|
+
relatedPagination: true,
|
|
32
|
+
/** Карусель моделей под похожими. */
|
|
33
|
+
relatedModels: true,
|
|
34
|
+
/** Блок Live Cams после похожих (при `FeatureWebcamsEnabled`); врезки в похожие берут хвост того же списка. */
|
|
35
|
+
webcamsBlock: false,
|
|
36
|
+
/** Сколько моделей из хвоста списка уходит во врезки похожих, когда блок Live Cams включён. */
|
|
37
|
+
webcamInserts: 8,
|
|
38
|
+
/** Мета ролика как на старых движках: description — до трёх фраз перехода (недостающие — из тегов), без них —
|
|
39
|
+
* категории, склейка ключами `meta.videos-slug.tags_and` / `tags_tail`; в title доступен `{thirdText}` — название
|
|
40
|
+
* в Title Case. Выключено — description из описания ролика. */
|
|
41
|
+
metaFromPhrases: false,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
@@ -319,6 +319,7 @@ $updated-word-width: 240px;
|
|
|
319
319
|
.card-playlist {
|
|
320
320
|
position: relative;
|
|
321
321
|
display: grid;
|
|
322
|
+
grid-template-columns: minmax(0, 1fr);
|
|
322
323
|
width: 100%;
|
|
323
324
|
container-type: inline-size;
|
|
324
325
|
align-items: start;
|
|
@@ -383,8 +384,10 @@ $updated-word-width: 240px;
|
|
|
383
384
|
.card-playlist__wrapper-header {
|
|
384
385
|
display: flex;
|
|
385
386
|
align-items: center;
|
|
387
|
+
flex-wrap: wrap;
|
|
386
388
|
justify-content: space-between;
|
|
387
389
|
column-gap: 24px;
|
|
390
|
+
row-gap: 4px;
|
|
388
391
|
margin-bottom: 8px;
|
|
389
392
|
}
|
|
390
393
|
|
|
@@ -84,7 +84,15 @@ const resultItems = computed(() => {
|
|
|
84
84
|
color: $breadcrumbs-text;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
.ui-breadcrumbs ol li:last-child {
|
|
88
|
+
min-width: 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
87
91
|
.ui-breadcrumbs ol li:last-child .ui-breadcrumbs__item {
|
|
92
|
+
min-width: 0;
|
|
93
|
+
overflow: hidden;
|
|
94
|
+
white-space: nowrap;
|
|
95
|
+
text-overflow: ellipsis;
|
|
88
96
|
color: $breadcrumbs-text--current;
|
|
89
97
|
text-transform: capitalize;
|
|
90
98
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CategoriesApiService } from '../../services/api/categories.service';
|
|
2
2
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
3
|
+
import { sortItemsDefault } from '../../lib';
|
|
3
4
|
import type { IBreadcrumbItem, ICardInfo, IChipsItem } from '../../types';
|
|
4
5
|
|
|
5
6
|
/** Слаги категорий, у которых не показываем связанные поисковые фразы. */
|
|
@@ -27,8 +28,15 @@ export async function useCategoryPageData() {
|
|
|
27
28
|
/**
|
|
28
29
|
* Логика страницы категории поверх `useCategoryPageData()`: мета и 404, хлебные крошки, номер страницы,
|
|
29
30
|
* карточка инфо и чипсы связанных фраз. Синхронная — вызывать в setup после `await useCategoryPageData()`.
|
|
31
|
+
*
|
|
32
|
+
* Мета по умолчанию — из API (`metaTitle` / `metaDescription`, без них — название), h1 — `header` или название.
|
|
33
|
+
* `metaKeys: true` — мета и h1 через `useMeta` по ключам `meta.<page>.<sort>.*` (`{text}` — название, с анти-дублем
|
|
34
|
+
* слова ниши); мета из API идёт через шаблоны `api_title` / `api_description`, пустая — по ключам.
|
|
30
35
|
*/
|
|
31
|
-
export function useCategoryPage(
|
|
36
|
+
export function useCategoryPage(
|
|
37
|
+
pageData: Awaited<ReturnType<typeof useCategoryPageData>>,
|
|
38
|
+
options: { metaKeys?: boolean } = {},
|
|
39
|
+
) {
|
|
32
40
|
const { categoryInfo, data, status, relatedData } = pageData;
|
|
33
41
|
const { t } = useI18n();
|
|
34
42
|
const route = useRoute();
|
|
@@ -39,7 +47,25 @@ export function useCategoryPage(pageData: Awaited<ReturnType<typeof useCategoryP
|
|
|
39
47
|
const pageText = computed(() => (pageNumber.value === 1 ? '' : ` ${t('page')} ${pageNumber.value}`));
|
|
40
48
|
|
|
41
49
|
const title = computed(() => categoryInfo.value?.title || '');
|
|
42
|
-
|
|
50
|
+
|
|
51
|
+
const keysMeta = options.metaKeys
|
|
52
|
+
? useMeta({
|
|
53
|
+
t,
|
|
54
|
+
pageKey: useGetPureRouteName(),
|
|
55
|
+
brandName: useAppConfig().brandName,
|
|
56
|
+
sortOptions: sortItemsDefault,
|
|
57
|
+
text: title,
|
|
58
|
+
nicheSafeText: title,
|
|
59
|
+
seo: () => ({
|
|
60
|
+
title: categoryInfo.value?.metaTitle || '',
|
|
61
|
+
description: categoryInfo.value?.metaDescription || '',
|
|
62
|
+
h1: '',
|
|
63
|
+
keywords: '',
|
|
64
|
+
}),
|
|
65
|
+
})
|
|
66
|
+
: null;
|
|
67
|
+
|
|
68
|
+
const h1 = computed(() => (keysMeta ? keysMeta.h1.value : categoryInfo.value?.header || title.value));
|
|
43
69
|
|
|
44
70
|
const categoryInfoResult = computed(() => ({
|
|
45
71
|
title: h1.value,
|
|
@@ -68,17 +94,21 @@ export function useCategoryPage(pageData: Awaited<ReturnType<typeof useCategoryP
|
|
|
68
94
|
]);
|
|
69
95
|
|
|
70
96
|
watchEffect(() => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
if (keysMeta) {
|
|
98
|
+
useHead(keysMeta.meta.value);
|
|
99
|
+
} else {
|
|
100
|
+
const metaTitle = `${categoryInfo.value?.metaTitle || title.value}${pageText.value}`;
|
|
101
|
+
const metaDescription = `${categoryInfo.value?.metaDescription || ''}${pageText.value}`;
|
|
102
|
+
|
|
103
|
+
useHead({
|
|
104
|
+
title: metaTitle,
|
|
105
|
+
meta: [
|
|
106
|
+
{ name: 'description', content: metaDescription },
|
|
107
|
+
{ property: 'og:title', content: metaTitle },
|
|
108
|
+
{ property: 'og:description', content: metaDescription },
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
82
112
|
|
|
83
113
|
if (status.value === 'success' && (!data.value || !categoryInfo.value)) {
|
|
84
114
|
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Ref } from 'vue';
|
|
2
2
|
import { convertString } from '../../runtime';
|
|
3
|
-
import type { IBreadcrumbItem, IChipsItem } from '../../types';
|
|
3
|
+
import type { IBreadcrumbItem, IChipsItem, INavLinkItem } from '../../types';
|
|
4
4
|
|
|
5
5
|
const RECENT_SEARCHES_KEY = 'recentSearches';
|
|
6
6
|
const RECENT_SEARCHES_LIMIT = 10;
|
|
@@ -8,9 +8,13 @@ const RECENT_SEARCHES_LIMIT = 10;
|
|
|
8
8
|
/**
|
|
9
9
|
* Логика страницы поиска, общая для любой разметки выдачи: мета и h1 по фразе, хлебные крошки, чипсы
|
|
10
10
|
* связанных фраз (без самой фразы) и запоминание фразы в недавних поисках. Запросы выдачи у страниц разные —
|
|
11
|
-
* их страница делает сама и передаёт сюда связанные фразы.
|
|
11
|
+
* их страница делает сама и передаёт сюда связанные фразы. `metaSortOptions` — сортировки выдачи, если у меты
|
|
12
|
+
* поиска есть варианты по ним (`meta.<page>.<sort>.*`); по умолчанию без уровня сортировки.
|
|
12
13
|
*/
|
|
13
|
-
export function useSearchPage(
|
|
14
|
+
export function useSearchPage(
|
|
15
|
+
relatedSearch: Ref<string[] | null | undefined>,
|
|
16
|
+
options: { metaSortOptions?: INavLinkItem[] } = {},
|
|
17
|
+
) {
|
|
14
18
|
const slug = useSlug();
|
|
15
19
|
const { t } = useI18n();
|
|
16
20
|
|
|
@@ -20,7 +24,7 @@ export function useSearchPage(relatedSearch: Ref<string[] | null | undefined>) {
|
|
|
20
24
|
t,
|
|
21
25
|
pageKey: useGetPureRouteName(),
|
|
22
26
|
brandName: useAppConfig().brandName,
|
|
23
|
-
sortOptions: [],
|
|
27
|
+
sortOptions: options.metaSortOptions || [],
|
|
24
28
|
text: metaTitle,
|
|
25
29
|
});
|
|
26
30
|
|
package/composables/use-meta.ts
CHANGED
|
@@ -51,10 +51,16 @@ type MetaParams = {
|
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* Генерирует реактивные meta (title, description) и h1 с учётом сортировки, страницы и i18n-ключей.
|
|
55
|
-
* Для не-дефолтной ниши сначала ищется вариант ключа `<key>_<niche>` (meta.index.trending.h1_gay)
|
|
56
|
-
* с теми же подстановками, без него — базовый ключ.
|
|
57
|
-
|
|
54
|
+
* Генерирует реактивные meta (title, description) и h1 с учётом сортировки, страницы и i18n-ключей.
|
|
55
|
+
* Для не-дефолтной ниши сначала ищется вариант ключа `<key>_<niche>` (meta.index.trending.h1_gay)
|
|
56
|
+
* с теми же подстановками, без него — базовый ключ.
|
|
57
|
+
*
|
|
58
|
+
* Страницы пагинации могут иметь свои тексты: для страницы > 1 сначала ищется ключ `<key>_paged` с подстановкой
|
|
59
|
+
* `{p}` (номер), без него — базовый ключ с `{page}`. Ниша решается раньше страницы: `<key>_paged_<niche>` →
|
|
60
|
+
* `<key>_<niche>`, без нишевого варианта — `<key>_paged` → `<key>`.
|
|
61
|
+
* Мета от API (`seo`) может идти через шаблон `api_title` / `api_description` (+ `_paged`) с `{text}` = значением
|
|
62
|
+
* API — сортировка добавляет префикс, страница суффикс. Нет таких ключей — всё как раньше.
|
|
63
|
+
*/
|
|
58
64
|
export function useMeta({ t, pageKey, brandName, sortOptions, sort, text, secondText, thirdText, fourthText, h1Key, seo, nicheSafeText }: MetaParams) {
|
|
59
65
|
const route = useRoute();
|
|
60
66
|
const { te, locale } = useI18n();
|
|
@@ -74,11 +80,13 @@ export function useMeta({ t, pageKey, brandName, sortOptions, sort, text, second
|
|
|
74
80
|
return queryValue && values?.includes(queryValue) ? queryValue : defaultValue;
|
|
75
81
|
});
|
|
76
82
|
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
return
|
|
83
|
+
const pageNumber = computed(() => {
|
|
84
|
+
const raw = Number(route.query?.['page']);
|
|
85
|
+
return Number.isFinite(raw) && raw > 1 ? Math.floor(raw) : 1;
|
|
80
86
|
});
|
|
81
87
|
|
|
88
|
+
const pageSuffix = computed(() => (pageNumber.value === 1 ? '' : ` ${t('page')} ${pageNumber.value}`));
|
|
89
|
+
|
|
82
90
|
const isNicheSuppressed = computed(() => {
|
|
83
91
|
const probe = toValue(nicheSafeText);
|
|
84
92
|
if (!probe || niche.value === defaultNiche) return false;
|
|
@@ -97,20 +105,48 @@ export function useMeta({ t, pageKey, brandName, sortOptions, sort, text, second
|
|
|
97
105
|
return te(variantKey) ? variantKey : baseKey;
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
function fullKey(key: string) {
|
|
109
|
+
return `meta.${pageKey}.${sortOptions && sortOptions.length > 0 ? `${sortType.value}.` : ''}${key}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Ключ с учётом страницы: на странице > 1 — `<key>_paged`, если он переведён. Ниша решается раньше страницы:
|
|
114
|
+
* у ключа с нишевым вариантом страничный берётся только нишевый (`_paged_<niche>`), иначе страничный
|
|
115
|
+
* вариант другой ниши подменил бы текст.
|
|
116
|
+
*/
|
|
117
|
+
function pagedKey(key: string) {
|
|
118
|
+
const base = fullKey(key);
|
|
119
|
+
const resolved = resolveKey(base);
|
|
120
|
+
|
|
121
|
+
if (pageNumber.value > 1) {
|
|
122
|
+
const paged = resolved === base ? `${base}_paged` : `${base}_paged_${niche.value}`;
|
|
123
|
+
if (te(paged)) return paged;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return resolved;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function params(extra: Record<string, unknown> = {}) {
|
|
130
|
+
return {
|
|
131
|
+
text: toValue(text),
|
|
132
|
+
secondText: toValue(secondText),
|
|
133
|
+
thirdText: toValue(thirdText),
|
|
134
|
+
fourText: toValue(fourthText),
|
|
135
|
+
brandName: `| ${brandName}`, //черточка '|' нужна обязательно
|
|
136
|
+
page: pageSuffix.value,
|
|
137
|
+
p: pageNumber.value,
|
|
138
|
+
...extra,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
100
142
|
function getPath(key: string) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
thirdText: toValue(thirdText),
|
|
109
|
-
fourText: toValue(fourthText),
|
|
110
|
-
brandName: `| ${brandName}`, //черточка '|' нужна обязательно
|
|
111
|
-
page: pageText,
|
|
112
|
-
}
|
|
113
|
-
);
|
|
143
|
+
return t(pagedKey(key), params());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Значение API через шаблон `api_<key>` (префикс сортировки, суффикс страницы), если он есть. */
|
|
147
|
+
function fromApi(key: string, value: string, fallback: () => string) {
|
|
148
|
+
const templated = pagedKey(`api_${key}`);
|
|
149
|
+
return te(templated) ? t(templated, params({ text: value })) : fallback();
|
|
114
150
|
}
|
|
115
151
|
|
|
116
152
|
const apiSeo = computed(() => toValue(seo) || null);
|
|
@@ -131,14 +167,16 @@ export function useMeta({ t, pageKey, brandName, sortOptions, sort, text, second
|
|
|
131
167
|
|
|
132
168
|
const metaTitle = computed(() => {
|
|
133
169
|
const apiTitle = apiSeo.value?.title;
|
|
134
|
-
return apiTitle ? withPageSuffix(apiTitle) : getPath('title');
|
|
170
|
+
return apiTitle ? fromApi('title', apiTitle, () => withPageSuffix(apiTitle)) : getPath('title');
|
|
135
171
|
});
|
|
136
172
|
|
|
137
173
|
const h1 = computed(() => apiSeo.value?.h1 || getPath(h1Key?.(sortType.value) || 'h1'));
|
|
138
174
|
|
|
139
175
|
const meta = computed(() => {
|
|
140
176
|
const apiDescription = apiSeo.value?.description;
|
|
141
|
-
const description = apiDescription
|
|
177
|
+
const description = apiDescription
|
|
178
|
+
? fromApi('description', apiDescription, () => `${apiDescription}${pageSuffix.value}`)
|
|
179
|
+
: getPath('meta_description');
|
|
142
180
|
const keywords = apiSeo.value?.keywords;
|
|
143
181
|
|
|
144
182
|
return {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "itube-specs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.1042",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"types": "./types/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
"scripts/spec.sh",
|
|
87
87
|
"SITE_UPDATES.md",
|
|
88
88
|
"nuxt.config.ts",
|
|
89
|
+
"app.config.ts",
|
|
89
90
|
"DOCS/"
|
|
90
91
|
],
|
|
91
92
|
"devDependencies": {
|
package/pages/videos/[slug].vue
CHANGED
|
@@ -34,6 +34,7 @@ import { CSS_BREAKPOINTS } from '../../config';
|
|
|
34
34
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
35
35
|
|
|
36
36
|
const pageName = useGetPureRouteName();
|
|
37
|
+
const metaFromPhrases = !!useAppConfig().videoPage?.metaFromPhrases;
|
|
37
38
|
|
|
38
39
|
const fetchRelated = featureFlags.VectorRelatedVideosEnabled
|
|
39
40
|
? useFetchVectorRelatedVideos(featureFlags.FetchByUrlEnabled ? VideosApiService.getVectorRelatedVideosByUrl : VideosApiService.getVectorRelatedVideosById)
|
|
@@ -44,11 +45,15 @@ const [
|
|
|
44
45
|
{ data: relatedVideos, status },
|
|
45
46
|
{ data: relatedSearch },
|
|
46
47
|
{ data: dataModels },
|
|
48
|
+
{ data: refererPhrases },
|
|
47
49
|
] = await Promise.all([
|
|
48
50
|
useFetchVideo(featureFlags.FetchByUrlEnabled ? VideosApiService.getVideoByUrl : VideosApiService.getVideoById),
|
|
49
51
|
fetchRelated,
|
|
50
52
|
useFetchRelatedSearch(SearchApiService.getPhrases),
|
|
51
53
|
useFetchTopModels(ModelsApiService.getTop, 24, AsyncData.TopModelsMore, String(pageName)),
|
|
54
|
+
metaFromPhrases
|
|
55
|
+
? useFetchVideoRefererPhrases(featureFlags.FetchByUrlEnabled ? VideosApiService.refererPhrasesByUrl : VideosApiService.refererPhrasesById)
|
|
56
|
+
: { data: ref<string[] | null>(null) },
|
|
52
57
|
]);
|
|
53
58
|
|
|
54
59
|
// Модели ролика для боковой колонки: у каждой своя карточка и её ролики. Запрос
|
|
@@ -75,7 +80,24 @@ useSetSeoOverride(() => ({
|
|
|
75
80
|
}));
|
|
76
81
|
|
|
77
82
|
const title = computed(() => videoData.value?.title || '');
|
|
78
|
-
|
|
83
|
+
|
|
84
|
+
// Go `title`: заглавная у каждого слова
|
|
85
|
+
const titleCase = (value: string) => value.replace(/(^|\s)(\S)/g, (_, space: string, letter: string) => `${space}${letter.toUpperCase()}`);
|
|
86
|
+
|
|
87
|
+
// `videoPage.metaFromPhrases`: до трёх фраз перехода (недостающие — из тегов), без них — категории.
|
|
88
|
+
// Описание самого ролика старый движок брал только у отредактированных вручную (`is_edited`), этот признак API не отдаёт.
|
|
89
|
+
const phrasesDescription = computed(() => {
|
|
90
|
+
const phrases = Array.isArray(refererPhrases.value) ? refererPhrases.value : [];
|
|
91
|
+
const tags = videoData.value?.tags || [];
|
|
92
|
+
const categories = (videoData.value?.categories || []).map(item => item.title);
|
|
93
|
+
const pick = (index: number) => phrases[index] || (phrases.length <= index ? tags[index] : '') || '';
|
|
94
|
+
const parts = [0, 1, 2].map(index => titleCase(pick(index) || categories[index] || ''));
|
|
95
|
+
const joined = `${parts[0]}${parts[1] ? `, ${parts[1]}` : ''}${parts[2] ? `${t('meta.videos-slug.tags_and')}${parts[2]}` : ''}`;
|
|
96
|
+
|
|
97
|
+
return joined ? `${joined}${t('meta.videos-slug.tags_tail')}` : '';
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const description = computed(() => (metaFromPhrases && phrasesDescription.value) || videoData.value?.description || '');
|
|
79
101
|
const pagePureName = useGetPureRouteName();
|
|
80
102
|
const { meta } = useMeta({
|
|
81
103
|
t,
|
|
@@ -84,6 +106,7 @@ const { meta } = useMeta({
|
|
|
84
106
|
sortOptions: [],
|
|
85
107
|
text: title,
|
|
86
108
|
secondText: description,
|
|
109
|
+
thirdText: metaFromPhrases ? () => titleCase(title.value) : undefined,
|
|
87
110
|
});
|
|
88
111
|
|
|
89
112
|
const webpThumbs = videoData.value?.thumbUrls?.webp;
|