itube-specs 0.0.1054 → 0.0.1056
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 +58 -0
- package/app.config.ts +46 -0
- package/assets/scss/base/_loading.scss +13 -1
- package/assets/scss/themes/dark/_theme.scss +3 -0
- package/assets/scss/themes/light/_theme.scss +3 -0
- package/components/grid/models.vue +28 -4
- package/components/grid/videos.vue +64 -10
- package/components/player/interface.vue +2 -1
- package/components/view/model-videos.vue +0 -1
- package/components/view/models.vue +3 -0
- package/components/view/video.vue +43 -26
- package/components/view/videos.vue +19 -0
- package/components/webcam/main.vue +22 -3
- package/composables/fetch/use-fetch-models-search.ts +44 -0
- package/composables/fetch/use-fetch-playlists-by-niche.ts +2 -1
- package/composables/fetch/use-fetch-related-searches-by-query.ts +26 -0
- package/composables/fetch/use-fetch-top-categories.ts +22 -0
- package/composables/fetch/use-fetch-videos-by-categories.ts +2 -1
- package/composables/fetch/use-fetch-videos-by-channel.ts +2 -1
- package/composables/fetch/use-fetch-videos-by-model.ts +2 -1
- package/composables/fetch/use-fetch-videos-by-tag.ts +2 -1
- package/composables/fetch/use-fetch-webcams.ts +8 -3
- package/composables/fetch/use-search-related-phrases.ts +22 -0
- package/composables/page/use-category-page.ts +7 -5
- package/composables/page/use-search-page.ts +1 -1
- package/composables/use-related-phrases.ts +56 -0
- package/i18n/locales/de-DE.json +1 -0
- package/i18n/locales/en-US.json +1 -0
- package/i18n/locales/es-ES.json +1 -0
- package/i18n/locales/fr-FR.json +1 -0
- package/i18n/locales/it-IT.json +1 -0
- package/i18n/locales/ja-JP.json +1 -0
- package/i18n/locales/ko-KR.json +1 -0
- package/i18n/locales/ru-RU.json +1 -0
- package/i18n/locales/zh-CN.json +1 -0
- package/package.json +1 -1
- package/pages/categories/[slug].vue +2 -0
- package/pages/models/[slug]/channel/[channel].vue +0 -3
- package/pages/models/[slug]/index.vue +0 -3
- package/pages/models/[slug]/tag/[tag].vue +0 -3
- package/pages/models/letter/[slug].vue +3 -10
- package/pages/playlists/[playlistId]/[slug].vue +3 -6
- package/pages/search/[slug]/index.vue +3 -4
- package/pages/search/[slug]/models.vue +1 -5
- package/pages/tag/[tag].vue +22 -1
- package/pages/videos/[slug].vue +10 -5
- package/runtime/constants/async-data.ts +3 -0
- package/runtime/constants/webcams.ts +13 -0
- package/runtime/index.ts +1 -0
- package/runtime/utils/__tests__/seeded-shuffle.test.ts +27 -0
- package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +12 -18
- package/runtime/utils/cleaners/clean-video-data.ts +15 -21
- package/runtime/utils/seeded-shuffle.ts +20 -0
- package/server/api/categories/get-top.get.ts +11 -0
- package/server/api/search/related-searches.get.ts +8 -0
- package/server/api/videos/get-video-by-id/[id].get.ts +3 -3
- package/server/api/videos/get-video-by-url/[url].get.ts +2 -3
- package/server/utils/video-demo-mock.ts +26 -0
- package/services/api/categories.service.ts +20 -0
- package/services/api/search.service.ts +27 -1
- package/types/raw/raw-video-data.d.ts +3 -2
- package/types/video-data.d.ts +0 -1
- package/utils/webcams/__tests__/insert-positions.test.ts +21 -1
- package/utils/webcams/insert-positions.ts +15 -0
- package/server/utils/video-tag-groups-mock.ts +0 -33
package/SITE_UPDATES.md
CHANGED
|
@@ -11,6 +11,64 @@
|
|
|
11
11
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
12
12
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
13
13
|
|
|
14
|
+
## 0.0.1056 — 2026-10-30
|
|
15
|
+
|
|
16
|
+
- **Всем — к сведению:** починена `/search/{q}/models` — метод получал аргументы не в том порядке (фраза уходила в
|
|
17
|
+
`order`), поэтому всегда приходила первая страница без сортировки. Теперь `useFetchModelsSearch`: страница и `?sort`
|
|
18
|
+
(по умолчанию `rank`) работают. Делать ничего не надо.
|
|
19
|
+
|
|
20
|
+
- **Всем — к сведению:** плеер плейлиста отдаёт 404 на удалённый ролик (`isDeleted`) и на ролик без файла (пустой
|
|
21
|
+
`md5`) — раньше рисовал его как живой. Делать ничего не надо.
|
|
22
|
+
|
|
23
|
+
- **Всем — к сведению:** убраны запросы, ответ на которые не выводился. Страница видео: топ-модели запрашиваются только
|
|
24
|
+
при `videoPage.relatedModels` и больше не держат рендер страницы (раньше упавшая ручка топ-моделей = нет страницы
|
|
25
|
+
ролика), модели боковой колонки на мобиле не запрашиваются. Плеер плейлиста — без групп категорий. `/models/letter/*`
|
|
26
|
+
— без топ-моделей (карусель строится из выдачи). Страница модели и её `tag`/`channel` — без похожих моделей; проп
|
|
27
|
+
`dataModels` у `ViewModelVideos` удалён. Делать ничего не надо (своих страниц модели с `ViewModelVideos` у сайтов нет).
|
|
28
|
+
|
|
29
|
+
- **Всем — к сведению:** раскладка вебкамов как на videosection.com — `appConfig.gridWebcams.legacy` (по умолчанию `false`,
|
|
30
|
+
всё как было). Включённая: один список 42 на страницу (`WEBCAMS_LEGACY_LAYOUT`), блок Live Cams — модели 0–11,
|
|
31
|
+
врезки в сетку видео — 21–24 после ¼, ½, ¾ сетки и за 4 карточки до конца (`webcamQuarterPositions`), мини-блок
|
|
32
|
+
после сетки видео и моделей — 25–36; на страницах по буквам моделей — без вебкамов. Новое без флага:
|
|
33
|
+
`useFetchWebcams({ random, id })`; у `WebcamMain` — `list` / `offset` / `random` / `id` / `hideCounts`; у `GridVideos`
|
|
34
|
+
и `ViewVideos` — `webcamsQuery` (`false` — без вебкамов; категория/random/размер списка — только в legacy); у
|
|
35
|
+
`GridModels` — `withWebcams`. Делать ничего не надо; образец — videosection-redesign.
|
|
36
|
+
|
|
37
|
+
- **Всем — к сведению:** новый ключ `appConfig.defaultOrders` (`category`, `tag`, `model`, `channel`, `playlists`) —
|
|
38
|
+
порядок выдачи без `?sort`; дефолт `null` = как было (`-popularity`, у плейлистов `-views`), `''` — `order` не
|
|
39
|
+
передаётся (дефолт API). Плюс `CategoriesApiService.getTop` + `useFetchTopCategories` — топ-категории в порядке API
|
|
40
|
+
(`/bff/categories/get-top`), для сайтов, которым не нужен `top-random`. Делать ничего не надо; образец —
|
|
41
|
+
videosection-redesign (`config/site.config.ts`, главная `pages/index.vue`).
|
|
42
|
+
|
|
43
|
+
- **Сайтам с `categoryRelatedSearchLimit` (из 0.0.1055):** ключ заменён на `appConfig.relatedSearches.categoryLimit` —
|
|
44
|
+
перенести значение; старый ключ больше не читается (без переноса на категории снова все фразы).
|
|
45
|
+
- **Всем — к сведению:** новый ключ `appConfig.relatedSearches` и `appConfig.videoPage.refererPhrasesPrefix`, все
|
|
46
|
+
дефолты = прежнее поведение, делать ничего не надо. Чтобы чипсы фраз совпадали с videosection.com:
|
|
47
|
+
`relatedSearches.shuffle` — топ-N вперемешку; `categoryLimit` — сколько фраз на категории; `queryLimit` — поиск
|
|
48
|
+
берёт фразы, похожие на запрос (`/video/related-searches`, новый BFF `/bff/search/related-searches`), и тег
|
|
49
|
+
получает такой же блок (проп `relatedSearch` у `ViewVideos`); `tagLimit` — свой лимит тега; `trendingLimit` —
|
|
50
|
+
сколько общих фраз под видео; `refererLimit` — сколько фраз перехода над видео (`playlistRefererLimit` — на плеере плейлиста); `mobile` — те же лимиты на мобиле
|
|
51
|
+
(`0` — блока нет; хелпер `useRelatedSearchLimit`);
|
|
52
|
+
`refererPhrasesPrefix: 'tag'` — фразы перехода над видео ведут на `/tag/…`. Образец — videosection-redesign
|
|
53
|
+
(`config/site.config.ts`). Сайтам со своей страницей поиска: чтобы она слушалась `queryLimit`, фразы брать через
|
|
54
|
+
`useSearchRelatedPhrases()` вместо `useFetchRelatedSearch(SearchApiService.getPhrases)` и отдавать в
|
|
55
|
+
`useSearchPage()` (образец — `pages/search/[slug]/index.vue` videosection-redesign); без этого всё как было.
|
|
56
|
+
|
|
57
|
+
## 0.0.1055 — 2026-10-30
|
|
58
|
+
|
|
59
|
+
- **Всем — к сведению:** related searches на странице категории (`useCategoryPage().relatedSearch`) теперь
|
|
60
|
+
отсортированы по убыванию `score` (раньше — в порядке ответа API). Новый ключ `appConfig.categoryRelatedSearchLimit`
|
|
61
|
+
(по умолчанию `null` — все пришедшие фразы, как было): число — показывать только топ-N по весу. Делать ничего не
|
|
62
|
+
надо; videosection-redesign ставит `20`, как на боевом videosection.
|
|
63
|
+
|
|
64
|
+
- **Всем — к сведению:** группы под плеером читаются из поля `entities` ответа видео v2 (бекенд: разделы категорий,
|
|
65
|
+
затем теги и поисковые фразы); `tags` снова только `string[]`. У узлов `tag`/`search` бекенд отдаёт в `title` сам
|
|
66
|
+
тип — заголовок берётся из переводов (новый ключ `tags`, и `related_searches`). BFF обеих ручек видео (по id и по
|
|
67
|
+
url) ходит в `GET /v2/video/url/{url}` — бекенд сам разбирает id или слаг; заглушка групп удалена. Временное демо (`server/utils/video-demo-mock.ts`): без описания — lorem, без разделов
|
|
68
|
+
категорий в `entities` — категории ролика одной группой «Categories»; уберём, когда данные появятся.
|
|
69
|
+
**Нужен бекенд с этой ручкой** (itube-gw `9d57077`): на API без неё страница видео отдаёт 500. Сайтам со своими локалями (hdsex `hi-IN`) — добавить `tags` и
|
|
70
|
+
`related_searches`, иначе заголовок будет на английском.
|
|
71
|
+
|
|
14
72
|
## 0.0.1054 — 2026-10-28
|
|
15
73
|
|
|
16
74
|
- **Сайтам со своей темой (hdsex-remaster):** в `_theme.scss` добавить токены тегов в раскрытом описании видео —
|
package/app.config.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { INavExtraItem, INavItemOverride, IQuickFilterScheme, ISecondNavItem } from './types';
|
|
2
2
|
|
|
3
|
+
type RelatedSearchLimitKey = 'categoryLimit' | 'queryLimit' | 'tagLimit' | 'trendingLimit' | 'refererLimit'
|
|
4
|
+
| 'playlistRefererLimit';
|
|
5
|
+
|
|
3
6
|
export default defineAppConfig({
|
|
4
7
|
// Дефолты, чтобы слой собирался/тестировался standalone.
|
|
5
8
|
// Проект deep-merge'ит свои per-site значения поверх.
|
|
@@ -14,12 +17,53 @@ export default defineAppConfig({
|
|
|
14
17
|
/** Есть ли у сайта сгруппированный вид категорий (`/categories` — группы, `/categories/all` — A–Z).
|
|
15
18
|
* `false` — `/categories` сам список A–Z: `CategoryNav` без табов Grouped / A-Z, «All» ведёт на `/categories`. */
|
|
16
19
|
categoriesGrouped: true,
|
|
20
|
+
/** Порядок выдачи без `?sort` (значение `order` API). `null` — дефолт слоя, `''` — `order` не передавать (дефолт
|
|
21
|
+
* API). Вкладки сортировок не меняет. */
|
|
22
|
+
defaultOrders: {
|
|
23
|
+
/** Видео категории, дефолт слоя `-popularity`. */
|
|
24
|
+
category: null as string | null,
|
|
25
|
+
/** Видео тега, дефолт слоя `-popularity`. */
|
|
26
|
+
tag: null as string | null,
|
|
27
|
+
/** Видео модели, дефолт слоя `-popularity`. */
|
|
28
|
+
model: null as string | null,
|
|
29
|
+
/** Видео канала, дефолт слоя `-popularity`. */
|
|
30
|
+
channel: null as string | null,
|
|
31
|
+
/** Список плейлистов, дефолт слоя `-views`. */
|
|
32
|
+
playlists: null as string | null,
|
|
33
|
+
},
|
|
34
|
+
/** Чипсы поисковых фраз в листингах и на видео (`useRelatedPhrases`). Фразы с весом сначала режутся до топа
|
|
35
|
+
* по `score`, без веса (общие фразы) — как пришли. */
|
|
36
|
+
relatedSearches: {
|
|
37
|
+
/** Показывать вперемешку (новый порядок на каждый серверный рендер, как на videosection.com); `false` — фразы
|
|
38
|
+
* с весом по убыванию `score`, без веса — как пришли. */
|
|
39
|
+
shuffle: false,
|
|
40
|
+
/** Сколько фраз на странице категории, `null` — все. */
|
|
41
|
+
categoryLimit: null as number | null,
|
|
42
|
+
/** Сколько фраз, похожих на запрос (`/video/related-searches`), на страницах поиска и тега. `null` — поиск
|
|
43
|
+
* показывает общие фразы (`/search/phrases`), у тега блока нет. */
|
|
44
|
+
queryLimit: null as number | null,
|
|
45
|
+
/** Сколько фраз на теге, `null` — как `queryLimit` (блок у тега есть, только когда `queryLimit` задан). */
|
|
46
|
+
tagLimit: null as number | null,
|
|
47
|
+
/** Сколько общих фраз в блоке под видео, `null` — все пришедшие. */
|
|
48
|
+
trendingLimit: null as number | null,
|
|
49
|
+
/** Сколько фраз перехода над видео (`videoPage.refererPhrases`), `null` — все пришедшие. */
|
|
50
|
+
refererLimit: null as number | null,
|
|
51
|
+
/** Сколько фраз перехода на плеере плейлиста, `null` — как `refererLimit`. */
|
|
52
|
+
playlistRefererLimit: null as number | null,
|
|
53
|
+
/** Те же лимиты на мобиле (`isMobile`): ключа нет — как на десктопе, `0` — блока нет. Источник фраз поиска и
|
|
54
|
+
* наличие блока у тега решает десктопный `queryLimit`. */
|
|
55
|
+
mobile: {} as Partial<Record<RelatedSearchLimitKey, number | null>>,
|
|
56
|
+
},
|
|
17
57
|
/** Блок Live Cams в сетках листингов (при `FeatureWebcamsEnabled`), `null` — нет. `videos` — после какой строки
|
|
18
58
|
* карточек и сколько врезок вебкам-тумб (хвост того же списка) в сетке видео листинга (не похожие, не плейлист,
|
|
19
59
|
* не когда страница передала врезки сама); `models` — в основной сетке моделей (`priority`, не футер). */
|
|
20
60
|
gridWebcams: {
|
|
21
61
|
videos: null as { afterRows: number, inserts: number } | null,
|
|
22
62
|
models: null as { afterRows: number } | null,
|
|
63
|
+
/** Раскладка videosection.com (`WEBCAMS_LEGACY_LAYOUT`): список 42 на страницу, блок — модели 0–11, врезки в сетку
|
|
64
|
+
* видео — 21–24 после ¼, ½, ¾ сетки и за 4 карточки до конца (`inserts` из `videos` не используется), мини-блок
|
|
65
|
+
* после сетки видео и моделей — 25–36. `false` — раскладка слоя: блок 12 + врезки из хвоста на случайных местах. */
|
|
66
|
+
legacy: false,
|
|
23
67
|
},
|
|
24
68
|
/** Старые значения `?sort` → ключ API сортировки редизайна (`''` — аналога нет, 301 на страницу без сортировки).
|
|
25
69
|
* Значения, совпадающие с ключом API сортировки страницы (`-created`, `rank`), узнаются и без карты;
|
|
@@ -31,6 +75,8 @@ export default defineAppConfig({
|
|
|
31
75
|
videoPage: {
|
|
32
76
|
/** Фразы, по которым пришли на ролик, — строкой над заголовком. */
|
|
33
77
|
refererPhrases: false,
|
|
78
|
+
/** Куда ведут фразы перехода: `search` — `/search/<фраза>`, `tag` — `/tag/<фраза>`. */
|
|
79
|
+
refererPhrasesPrefix: 'search' as 'search' | 'tag',
|
|
34
80
|
/** Пагинация похожих. */
|
|
35
81
|
relatedPagination: true,
|
|
36
82
|
/** Карусель моделей под похожими. */
|
|
@@ -21,6 +21,18 @@
|
|
|
21
21
|
display: block;
|
|
22
22
|
width: var(--size, 64px);
|
|
23
23
|
height: var(--size, 64px);
|
|
24
|
-
|
|
24
|
+
background-color: $loading-icon;
|
|
25
|
+
content: '';
|
|
26
|
+
mask-position: center;
|
|
27
|
+
mask-size: contain;
|
|
28
|
+
mask-repeat: no-repeat;
|
|
29
|
+
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><radialGradient id="a12" cx=".66" fx=".66" cy=".3125" fy=".3125" gradientTransform="scale(1.5)"><stop offset="0" stop-color="%23FFFFFF"></stop><stop offset=".3" stop-color="%23FFFFFF" stop-opacity=".9"></stop><stop offset=".6" stop-color="%23FFFFFF" stop-opacity=".6"></stop><stop offset=".8" stop-color="%23FFFFFF" stop-opacity=".3"></stop><stop offset="1" stop-color="%23FFFFFF" stop-opacity="0"></stop></radialGradient><circle transform-origin="center" fill="none" stroke="url(%23a12)" stroke-width="15" stroke-linecap="round" stroke-dasharray="200 1000" stroke-dashoffset="0" cx="100" cy="100" r="70"></circle><circle transform-origin="center" fill="none" opacity=".2" stroke="%23FFFFFF" stroke-width="15" stroke-linecap="round" cx="100" cy="100" r="70"></circle></svg>');
|
|
30
|
+
animation: loading-spin 2s linear infinite;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@keyframes loading-spin {
|
|
35
|
+
to {
|
|
36
|
+
transform: translate(-50%, -50%) rotate(-360deg);
|
|
25
37
|
}
|
|
26
38
|
}
|
|
@@ -18,30 +18,48 @@
|
|
|
18
18
|
v-if="webcams"
|
|
19
19
|
:after-rows="webcams.afterRows"
|
|
20
20
|
>
|
|
21
|
-
<LazyWebcamMain
|
|
21
|
+
<LazyWebcamMain
|
|
22
|
+
:list="legacy ? WEBCAMS_LEGACY_LAYOUT.list : undefined"
|
|
23
|
+
hydrate-on-visible
|
|
24
|
+
/>
|
|
22
25
|
</GridInsert>
|
|
23
26
|
<slot/>
|
|
24
27
|
<GridModelsEndSlot
|
|
25
28
|
:items="items"
|
|
26
29
|
:footer="footer"
|
|
27
30
|
/>
|
|
31
|
+
<LazyWebcamMain
|
|
32
|
+
v-if="webcams && legacy"
|
|
33
|
+
:list="WEBCAMS_LEGACY_LAYOUT.list"
|
|
34
|
+
:offset="WEBCAMS_LEGACY_LAYOUT.mini[0]"
|
|
35
|
+
:limit="WEBCAMS_LEGACY_LAYOUT.mini[1]"
|
|
36
|
+
class="grid-models__webcams-mini"
|
|
37
|
+
hide-counts
|
|
38
|
+
hydrate-on-visible
|
|
39
|
+
/>
|
|
28
40
|
</div>
|
|
29
41
|
</template>
|
|
30
42
|
|
|
31
43
|
<script setup lang="ts">
|
|
32
44
|
import type { IModelCard } from '../../types';
|
|
45
|
+
import { WEBCAMS_LEGACY_LAYOUT } from '../../runtime';
|
|
33
46
|
|
|
34
|
-
const props = defineProps<{
|
|
47
|
+
const props = withDefaults(defineProps<{
|
|
35
48
|
items: IModelCard[]
|
|
36
49
|
footer?: boolean
|
|
37
50
|
priority?: boolean
|
|
38
|
-
|
|
51
|
+
/** `false` — без блока Live Cams даже в основной сетке. */
|
|
52
|
+
withWebcams?: boolean
|
|
53
|
+
}>(), {
|
|
54
|
+
withWebcams: true,
|
|
55
|
+
});
|
|
39
56
|
|
|
40
57
|
const appConfig = useAppConfig();
|
|
41
58
|
const cardTriggerClass = appConfig.cardTriggerClass || undefined;
|
|
59
|
+
const legacy = !!appConfig.gridWebcams?.legacy;
|
|
42
60
|
|
|
43
61
|
/** Блок Live Cams — только в основной сетке страницы (`priority`), не в футере и не в сетках по буквам. */
|
|
44
|
-
const webcams = !props.footer && props.priority ? appConfig.gridWebcams?.models : null;
|
|
62
|
+
const webcams = !props.footer && props.priority && props.withWebcams ? appConfig.gridWebcams?.models : null;
|
|
45
63
|
</script>
|
|
46
64
|
|
|
47
65
|
<style lang="scss">
|
|
@@ -77,4 +95,10 @@ const webcams = !props.footer && props.priority ? appConfig.gridWebcams?.models
|
|
|
77
95
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
78
96
|
}
|
|
79
97
|
}
|
|
98
|
+
|
|
99
|
+
.grid-models__webcams-mini {
|
|
100
|
+
grid-column: 1/-1;
|
|
101
|
+
margin-top: 24px;
|
|
102
|
+
margin-bottom: 0;
|
|
103
|
+
}
|
|
80
104
|
</style>
|
|
@@ -46,6 +46,12 @@
|
|
|
46
46
|
:after-rows="webcams.afterRows"
|
|
47
47
|
>
|
|
48
48
|
<LazyWebcamMain
|
|
49
|
+
v-if="legacyList"
|
|
50
|
+
v-bind="legacyList"
|
|
51
|
+
hydrate-on-visible
|
|
52
|
+
/>
|
|
53
|
+
<LazyWebcamMain
|
|
54
|
+
v-else
|
|
49
55
|
:reserve-tail="webcams.inserts"
|
|
50
56
|
hydrate-on-visible
|
|
51
57
|
/>
|
|
@@ -107,12 +113,22 @@
|
|
|
107
113
|
:items="items"
|
|
108
114
|
:related="related"
|
|
109
115
|
/>
|
|
116
|
+
<LazyWebcamMain
|
|
117
|
+
v-if="legacyList"
|
|
118
|
+
v-bind="legacyList"
|
|
119
|
+
:offset="WEBCAMS_LEGACY_LAYOUT.mini[0]"
|
|
120
|
+
:limit="WEBCAMS_LEGACY_LAYOUT.mini[1]"
|
|
121
|
+
class="grid-videos__webcams-mini"
|
|
122
|
+
hide-counts
|
|
123
|
+
hydrate-on-visible
|
|
124
|
+
/>
|
|
110
125
|
</div>
|
|
111
126
|
</template>
|
|
112
127
|
|
|
113
128
|
<script setup lang="ts">
|
|
114
129
|
import type { IStripcashModel, IVideoCard } from '../../types';
|
|
115
|
-
import {
|
|
130
|
+
import { WEBCAMS_LEGACY_LAYOUT } from '../../runtime';
|
|
131
|
+
import { webcamInsertPositions, webcamQuarterPositions } from '../../utils/webcams/insert-positions';
|
|
116
132
|
|
|
117
133
|
/** Ячейка сетки: либо карточка видео, либо врезанная вебкам-тумба. */
|
|
118
134
|
interface IGridCell {
|
|
@@ -141,6 +157,11 @@ const props = defineProps<{
|
|
|
141
157
|
inserts?: IStripcashModel[]
|
|
142
158
|
/** Сид раскладки врезок; по умолчанию — guid первого видео. */
|
|
143
159
|
insertSeed?: string
|
|
160
|
+
/**
|
|
161
|
+
* Выборка вебкамов листинга при `gridWebcams.legacy`: категория провайдера, `random` и размер списка (по умолчанию
|
|
162
|
+
* `WEBCAMS_LEGACY_LAYOUT.list`, случайные). `false` — без блока и врезок (в любой раскладке).
|
|
163
|
+
*/
|
|
164
|
+
webcamsQuery?: { category?: string, random?: boolean, list?: number } | false
|
|
144
165
|
}>();
|
|
145
166
|
|
|
146
167
|
/** Клики и показы врезанных тумб уходят наружу — статистику отправляет сайт. */
|
|
@@ -165,9 +186,30 @@ function onInsertView(cell: IGridCell, element: HTMLElement) {
|
|
|
165
186
|
const appConfig = useAppConfig();
|
|
166
187
|
const cardTriggerClass = appConfig.cardTriggerClass || undefined;
|
|
167
188
|
|
|
189
|
+
const legacy = !!appConfig.gridWebcams?.legacy;
|
|
190
|
+
|
|
168
191
|
/** Блок Live Cams и свои врезки — у листингов (похожие и плейлист без них, страница видео передаёт врезки сама). */
|
|
169
|
-
const webcams = !props.inserts && !props.related && !props.playlist
|
|
170
|
-
|
|
192
|
+
const webcams = !props.inserts && !props.related && !props.playlist && props.webcamsQuery !== false
|
|
193
|
+
? appConfig.gridWebcams?.videos
|
|
194
|
+
: null;
|
|
195
|
+
|
|
196
|
+
/** Общий список страницы в раскладке videosection.com (`gridWebcams.legacy`): блок, врезки и мини-блок режут его по индексам. */
|
|
197
|
+
const legacyList = webcams && legacy
|
|
198
|
+
? {
|
|
199
|
+
list: (props.webcamsQuery && props.webcamsQuery.list) || WEBCAMS_LEGACY_LAYOUT.list,
|
|
200
|
+
category: (props.webcamsQuery && props.webcamsQuery.category) || undefined,
|
|
201
|
+
random: props.webcamsQuery ? props.webcamsQuery.random !== false : true,
|
|
202
|
+
}
|
|
203
|
+
: null;
|
|
204
|
+
|
|
205
|
+
const [insertsFrom, insertsCount] = WEBCAMS_LEGACY_LAYOUT.inserts;
|
|
206
|
+
|
|
207
|
+
const legacyItems = legacyList
|
|
208
|
+
? (await useFetchWebcams({ limit: legacyList.list, category: legacyList.category, random: legacyList.random, keepAlive: true })).items
|
|
209
|
+
: null;
|
|
210
|
+
const ownInserts = legacyItems
|
|
211
|
+
? computed(() => legacyItems.value.slice(insertsFrom, insertsFrom + insertsCount))
|
|
212
|
+
: webcams ? await useWebcamsShare(webcams.inserts) : null;
|
|
171
213
|
const inserts = computed(() => props.inserts || ownInserts?.value || []);
|
|
172
214
|
|
|
173
215
|
const isMobile = useState<boolean>('isMobile');
|
|
@@ -175,17 +217,19 @@ const eagerCount = computed(() => isMobile.value ? 4 : 12);
|
|
|
175
217
|
|
|
176
218
|
const cells = computed<IGridCell[]>(() => {
|
|
177
219
|
const models = inserts.value;
|
|
178
|
-
const positions =
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
220
|
+
const positions = legacy
|
|
221
|
+
? webcamQuarterPositions(props.items.length, models.length)
|
|
222
|
+
: webcamInsertPositions(
|
|
223
|
+
props.insertSeed || props.items[0]?.guid || '',
|
|
224
|
+
props.items.length,
|
|
225
|
+
models.length,
|
|
226
|
+
);
|
|
183
227
|
|
|
184
228
|
const result: IGridCell[] = [];
|
|
185
229
|
let inserted = 0;
|
|
186
230
|
|
|
187
|
-
|
|
188
|
-
|
|
231
|
+
const pushInserts = (index: number) => {
|
|
232
|
+
while (inserted < positions.length && positions[inserted] === index) {
|
|
189
233
|
const model = models[inserted];
|
|
190
234
|
|
|
191
235
|
result.push({
|
|
@@ -196,9 +240,13 @@ const cells = computed<IGridCell[]>(() => {
|
|
|
196
240
|
});
|
|
197
241
|
inserted++;
|
|
198
242
|
}
|
|
243
|
+
};
|
|
199
244
|
|
|
245
|
+
props.items.forEach((video, index) => {
|
|
246
|
+
pushInserts(index);
|
|
200
247
|
result.push({ key: `video-${video.guid}`, video, videoIndex: index, insertIndex: -1 });
|
|
201
248
|
});
|
|
249
|
+
pushInserts(props.items.length);
|
|
202
250
|
|
|
203
251
|
return result;
|
|
204
252
|
});
|
|
@@ -224,6 +272,12 @@ const lazyCells = computed(() => cells.value.slice(eagerCount.value + IDLE_COUNT
|
|
|
224
272
|
min-width: 0;
|
|
225
273
|
}
|
|
226
274
|
|
|
275
|
+
.grid-videos__webcams-mini {
|
|
276
|
+
grid-column: 1/-1;
|
|
277
|
+
margin-top: 24px;
|
|
278
|
+
margin-bottom: 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
227
281
|
.grid-videos.--first-hidden .grid-videos__card.--first-row {
|
|
228
282
|
@include from-br(md) {
|
|
229
283
|
display: none;
|
|
@@ -87,7 +87,8 @@
|
|
|
87
87
|
:key="group.name || group.title"
|
|
88
88
|
class="player-interface__tags-group"
|
|
89
89
|
>
|
|
90
|
-
|
|
90
|
+
<!-- у узлов тегов и поисков бекенд кладёт в title сам тип — заголовок берём из переводов -->
|
|
91
|
+
<dt class="player-interface__tags-title">{{ group.type === 'category' ? group.title : t(group.type === 'tag' ? 'tags' : 'related_searches') }}</dt>
|
|
91
92
|
<dd class="player-interface__tags-list">
|
|
92
93
|
<UiChips
|
|
93
94
|
v-for="item in group.items"
|
|
@@ -120,7 +120,6 @@ const props = defineProps<{
|
|
|
120
120
|
status: AsyncDataRequestStatus
|
|
121
121
|
statusOther?: AsyncDataRequestStatus
|
|
122
122
|
cardInfo: ICardInfo | null
|
|
123
|
-
dataModels: PaginatedResponse<IModelCard> | null
|
|
124
123
|
modelTags: IPopularTags[] | null
|
|
125
124
|
modelChannels: PaginatedResponse<IChannelCard> | null
|
|
126
125
|
similarModels?: IModelCard[] | undefined
|
|
@@ -101,6 +101,7 @@
|
|
|
101
101
|
<GridModels
|
|
102
102
|
:class="{'_loading': status === 'pending'}"
|
|
103
103
|
:items="data.items"
|
|
104
|
+
:with-webcams="!(letter && legacyWebcams)"
|
|
104
105
|
class="view-models__grid"
|
|
105
106
|
id="anchor"
|
|
106
107
|
priority
|
|
@@ -153,6 +154,8 @@ const { generateLink } = useGenerateLink();
|
|
|
153
154
|
const webcamsPath = useWebcamsPath();
|
|
154
155
|
|
|
155
156
|
const isWebcamsEnabled = !!useRuntimeConfig().public.featureFlags.FeatureWebcamsEnabled;
|
|
157
|
+
/** Раскладка videosection.com (`gridWebcams.legacy`): на буквенных страницах Live Cams нет. */
|
|
158
|
+
const legacyWebcams = !!useAppConfig().gridWebcams?.legacy;
|
|
156
159
|
|
|
157
160
|
// онлайн — тем же ключом фетча, что у блока «Live Cams» (второго запроса нет); всего моделей — той же ручкой,
|
|
158
161
|
// что на странице вебкамов, чтобы счётчик в переключателе на обеих страницах совпадал. Без флага запросов нет.
|
|
@@ -103,7 +103,12 @@
|
|
|
103
103
|
/>
|
|
104
104
|
|
|
105
105
|
<WebcamMain
|
|
106
|
-
v-if="videoPage.webcamsBlock"
|
|
106
|
+
v-if="videoPage.webcamsBlock && legacyWebcams"
|
|
107
|
+
v-bind="legacyWebcams"
|
|
108
|
+
class="_mt-24"
|
|
109
|
+
/>
|
|
110
|
+
<WebcamMain
|
|
111
|
+
v-else-if="videoPage.webcamsBlock"
|
|
107
112
|
:reserve-tail="videoPage.webcamInserts"
|
|
108
113
|
class="_mt-24"
|
|
109
114
|
/>
|
|
@@ -126,7 +131,7 @@
|
|
|
126
131
|
|
|
127
132
|
<script setup lang="ts">
|
|
128
133
|
import type { IChipsItem, IModelCard, IStripcashModel, IVideoCard, IVideoData, PaginatedResponse } from '../../types';
|
|
129
|
-
import { convertModelToRoundCard, convertString, convertTagGroupItemToChips } from '../../runtime';
|
|
134
|
+
import { WEBCAMS_LEGACY_LAYOUT, convertModelToRoundCard, convertString, convertTagGroupItemToChips } from '../../runtime';
|
|
130
135
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
131
136
|
import type { AsyncDataRequestStatus } from '#app';
|
|
132
137
|
|
|
@@ -159,16 +164,32 @@ const videoPage = useAppConfig().videoPage;
|
|
|
159
164
|
// Без блока Live Cams запрос клиентский: сетка ниже первого экрана, и рендер страницы видео не должен
|
|
160
165
|
// ждать провайдера. С блоком список один на двоих (`useWebcamsShare`).
|
|
161
166
|
// Без флага `FeatureWebcamsEnabled` запроса нет вовсе
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
167
|
+
// В раскладке videosection.com (`gridWebcams.legacy`) — общий список из 42: на десктопе ролика модели под ролик (`id`),
|
|
168
|
+
// на мобиле и в плеере плейлиста случайные; блок берёт 0–11, врезки — 21–24.
|
|
169
|
+
const legacyWebcams = useAppConfig().gridWebcams?.legacy
|
|
170
|
+
? props.page !== 'playlist' && !useState<boolean>('isMobile').value
|
|
171
|
+
? { list: WEBCAMS_LEGACY_LAYOUT.list, id: props.data.id, random: false }
|
|
172
|
+
: { list: WEBCAMS_LEGACY_LAYOUT.list, random: true }
|
|
173
|
+
: null;
|
|
174
|
+
|
|
175
|
+
const legacyWebcamItems = legacyWebcams && videoPage.webcamsBlock
|
|
176
|
+
? (await useFetchWebcams({ limit: legacyWebcams.list, id: legacyWebcams.id, random: legacyWebcams.random, keepAlive: true })).items
|
|
177
|
+
: null;
|
|
178
|
+
|
|
179
|
+
const webcamInserts = legacyWebcamItems
|
|
180
|
+
? computed(() => legacyWebcamItems.value.slice(WEBCAMS_LEGACY_LAYOUT.inserts[0], WEBCAMS_LEGACY_LAYOUT.inserts[0] + WEBCAMS_LEGACY_LAYOUT.inserts[1]))
|
|
181
|
+
: videoPage.webcamsBlock
|
|
182
|
+
? await useWebcamsShare(videoPage.webcamInserts)
|
|
183
|
+
: (await useFetchWebcams({ clientOnly: true })).items;
|
|
165
184
|
|
|
166
185
|
const { data: refererPhrases } = videoPage.refererPhrases
|
|
167
186
|
? await useFetchVideoRefererPhrases(featureFlags.FetchByUrlEnabled ? VideosApiService.refererPhrasesByUrl : VideosApiService.refererPhrasesById)
|
|
168
187
|
: { data: ref<string[] | null>(null) };
|
|
169
188
|
|
|
170
|
-
const
|
|
171
|
-
|
|
189
|
+
const refererList = useRelatedPhrases(() => (Array.isArray(refererPhrases.value) ? refererPhrases.value : []), useRelatedSearchLimit(props.page === 'playlist' ? 'playlistRefererLimit' : 'refererLimit'));
|
|
190
|
+
|
|
191
|
+
const refererChips = computed<IChipsItem[]>(() => refererList.value
|
|
192
|
+
.map(item => ({ title: item, value: item, prefix: videoPage.refererPhrasesPrefix })));
|
|
172
193
|
|
|
173
194
|
const modelsRoundCards = computed(() =>
|
|
174
195
|
props.models?.map(convertModelToRoundCard) || []
|
|
@@ -183,8 +204,9 @@ const emit = defineEmits<{
|
|
|
183
204
|
|
|
184
205
|
const tagChips = computed((): IChipsItem[] => props.data.tags?.map(item => ({ title: item, value: item, prefix: 'tag' })) ?? []);
|
|
185
206
|
|
|
186
|
-
// v2:
|
|
207
|
+
// v2: теги и поиски из групп (без повторов). Категории — из `categories`: в группы попадают только те, у кого есть раздел
|
|
187
208
|
const tagGroupChips = computed((): IChipsItem[] => [...new Map((props.data.tagGroups ?? [])
|
|
209
|
+
.filter(group => group.type !== 'category')
|
|
188
210
|
.flatMap(group => group.items.map(item => convertTagGroupItemToChips(group.type, item)))
|
|
189
211
|
.map(chip => [`${chip.prefix}/${chip.value}`, chip])).values()]);
|
|
190
212
|
|
|
@@ -198,24 +220,19 @@ const categoriesChips = computed((): IChipsItem[] => props.data.categories && pr
|
|
|
198
220
|
|
|
199
221
|
const categoriesTitles = computed<string[]>(() => props.data.categories && props.data.categories?.map(item => item.title?.replace(/\s+/g, '').toLowerCase() ?? ''));
|
|
200
222
|
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
} else {
|
|
205
|
-
return tagChips.value;
|
|
206
|
-
}
|
|
207
|
-
});
|
|
223
|
+
const withoutCategoryTitles = (chips: IChipsItem[]) => (categoriesTitles.value?.length
|
|
224
|
+
? chips.filter(chip => !categoriesTitles.value.includes(chip.title.replace(/\s+/g, '').toLowerCase()))
|
|
225
|
+
: chips);
|
|
208
226
|
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
});
|
|
227
|
+
const filterTagChips = computed(() => withoutCategoryTitles(tagChips.value));
|
|
228
|
+
|
|
229
|
+
const recentList = useRelatedPhrases(() => (Array.isArray(props.recent) ? props.recent : []),useRelatedSearchLimit('trendingLimit'));
|
|
230
|
+
|
|
231
|
+
const recentSearches = computed(() => recentList.value.map(item => ({
|
|
232
|
+
value: item,
|
|
233
|
+
title: item,
|
|
234
|
+
prefix: 'search',
|
|
235
|
+
} as IChipsItem)));
|
|
219
236
|
|
|
220
237
|
const topChips = computed(() => {
|
|
221
238
|
if (props.chips && props.chips.length > 0) {
|
|
@@ -225,7 +242,7 @@ const topChips = computed(() => {
|
|
|
225
242
|
prefix: 'search',
|
|
226
243
|
} as IChipsItem));
|
|
227
244
|
} else if (tagGroupChips.value.length) {
|
|
228
|
-
return tagGroupChips.value;
|
|
245
|
+
return [...categoriesChips.value || [], ...withoutCategoryTitles(tagGroupChips.value)];
|
|
229
246
|
} else if (categoriesChips.value || filterTagChips.value) {
|
|
230
247
|
return [...categoriesChips.value || [], ...filterTagChips.value || []];
|
|
231
248
|
}
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
<GridVideos
|
|
31
31
|
v-if="data && data.items && data.items?.length > 0"
|
|
32
32
|
:items="data.items"
|
|
33
|
+
:webcams-query="webcamsQuery"
|
|
33
34
|
:class="{'_loading': status === 'pending'}"
|
|
34
35
|
class="view-videos__grid"
|
|
35
36
|
id="anchor"
|
|
@@ -40,6 +41,13 @@
|
|
|
40
41
|
<AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
|
|
41
42
|
</template>
|
|
42
43
|
|
|
44
|
+
<UiExpandRow
|
|
45
|
+
v-if="relatedSearch && relatedSearch.length > 0"
|
|
46
|
+
:items="relatedSearch"
|
|
47
|
+
:pre-text="`${$t('related_searches')}: `"
|
|
48
|
+
class="view-videos__related-searches"
|
|
49
|
+
/>
|
|
50
|
+
|
|
43
51
|
<LazySectionSwiperCards
|
|
44
52
|
v-if="dataModels && dataModels?.items && dataModels?.items.length > 0"
|
|
45
53
|
:items="modelsRoundCards"
|
|
@@ -78,6 +86,7 @@
|
|
|
78
86
|
|
|
79
87
|
<script setup lang="ts">
|
|
80
88
|
import type {
|
|
89
|
+
IChipsItem,
|
|
81
90
|
IModelCard,
|
|
82
91
|
IModelFilter,
|
|
83
92
|
INavLinkItem,
|
|
@@ -106,6 +115,10 @@ const props = defineProps<{
|
|
|
106
115
|
/** Строка главных фильтров: группы категорий, приведённые `toQuickFilters`. Состояние
|
|
107
116
|
* живёт в общем `categories`, поэтому строка синхронна попапу фильтра и чипсам. */
|
|
108
117
|
quickFilters?: IModelFilter[]
|
|
118
|
+
/** Чипсы поисковых фраз во второй строке грида, как на странице категории. */
|
|
119
|
+
relatedSearch?: IChipsItem[]
|
|
120
|
+
/** Выборка вебкамов сетки (`GridVideos`). */
|
|
121
|
+
webcamsQuery?: { category?: string, random?: boolean, list?: number } | false
|
|
109
122
|
}>();
|
|
110
123
|
|
|
111
124
|
const route = useRoute();
|
|
@@ -176,6 +189,12 @@ const modelsRoundCards = computed(() =>
|
|
|
176
189
|
grid-column: 1 / -1;
|
|
177
190
|
}
|
|
178
191
|
|
|
192
|
+
.view-videos__related-searches {
|
|
193
|
+
grid-column: 1/-1;
|
|
194
|
+
grid-row: 2;
|
|
195
|
+
margin-block: 8px;
|
|
196
|
+
}
|
|
197
|
+
|
|
179
198
|
.view-videos__popular-models {
|
|
180
199
|
grid-row: 4;
|
|
181
200
|
grid-column: 1/-1;
|