itube-specs 0.0.1055 → 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 +43 -0
- package/app.config.ts +46 -2
- package/components/grid/models.vue +28 -4
- package/components/grid/videos.vue +64 -10
- package/components/view/model-videos.vue +0 -1
- package/components/view/models.vue +3 -0
- package/components/view/video.vue +35 -17
- 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 +5 -9
- package/composables/page/use-search-page.ts +1 -1
- package/composables/use-related-phrases.ts +56 -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/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/services/api/categories.service.ts +20 -0
- package/services/api/search.service.ts +27 -1
- package/utils/webcams/__tests__/insert-positions.test.ts +21 -1
- package/utils/webcams/insert-positions.ts +15 -0
package/SITE_UPDATES.md
CHANGED
|
@@ -11,6 +11,49 @@
|
|
|
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
|
+
|
|
14
57
|
## 0.0.1055 — 2026-10-30
|
|
15
58
|
|
|
16
59
|
- **Всем — к сведению:** related searches на странице категории (`useCategoryPage().relatedSearch`) теперь
|
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,14 +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,
|
|
17
|
-
/**
|
|
18
|
-
|
|
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
|
+
},
|
|
19
57
|
/** Блок Live Cams в сетках листингов (при `FeatureWebcamsEnabled`), `null` — нет. `videos` — после какой строки
|
|
20
58
|
* карточек и сколько врезок вебкам-тумб (хвост того же списка) в сетке видео листинга (не похожие, не плейлист,
|
|
21
59
|
* не когда страница передала врезки сама); `models` — в основной сетке моделей (`priority`, не футер). */
|
|
22
60
|
gridWebcams: {
|
|
23
61
|
videos: null as { afterRows: number, inserts: number } | null,
|
|
24
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,
|
|
25
67
|
},
|
|
26
68
|
/** Старые значения `?sort` → ключ API сортировки редизайна (`''` — аналога нет, 301 на страницу без сортировки).
|
|
27
69
|
* Значения, совпадающие с ключом API сортировки страницы (`-created`, `rank`), узнаются и без карты;
|
|
@@ -33,6 +75,8 @@ export default defineAppConfig({
|
|
|
33
75
|
videoPage: {
|
|
34
76
|
/** Фразы, по которым пришли на ролик, — строкой над заголовком. */
|
|
35
77
|
refererPhrases: false,
|
|
78
|
+
/** Куда ведут фразы перехода: `search` — `/search/<фраза>`, `tag` — `/tag/<фраза>`. */
|
|
79
|
+
refererPhrasesPrefix: 'search' as 'search' | 'tag',
|
|
36
80
|
/** Пагинация похожих. */
|
|
37
81
|
relatedPagination: true,
|
|
38
82
|
/** Карусель моделей под похожими. */
|
|
@@ -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;
|
|
@@ -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) || []
|
|
@@ -205,16 +226,13 @@ const withoutCategoryTitles = (chips: IChipsItem[]) => (categoriesTitles.value?.
|
|
|
205
226
|
|
|
206
227
|
const filterTagChips = computed(() => withoutCategoryTitles(tagChips.value));
|
|
207
228
|
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
prefix: 'search',
|
|
216
|
-
} as IChipsItem));
|
|
217
|
-
});
|
|
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)));
|
|
218
236
|
|
|
219
237
|
const topChips = computed(() => {
|
|
220
238
|
if (props.chips && props.chips.length > 0) {
|
|
@@ -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;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
v-if="isEnabled && visibleItems.length"
|
|
16
16
|
class="webcam-main"
|
|
17
17
|
>
|
|
18
|
-
<WebcamHeader :counts="counts"/>
|
|
18
|
+
<WebcamHeader :counts="hideCounts ? NO_COUNTS : counts"/>
|
|
19
19
|
|
|
20
20
|
<GridWebcams
|
|
21
21
|
:items="visibleItems"
|
|
@@ -42,10 +42,27 @@ const props = withDefaults(defineProps<{
|
|
|
42
42
|
* По умолчанию 0 — блок ведёт себя как раньше.
|
|
43
43
|
*/
|
|
44
44
|
reserveTail?: number
|
|
45
|
+
/**
|
|
46
|
+
* Размер общего списка страницы (раскладка videosection.com, `gridWebcams.legacy`): блок просит у провайдера `list` моделей и берёт из них
|
|
47
|
+
* `limit` начиная с `offset`. Задан — `reserveTail` не нужен; ключ запроса тот же, что у врезок и других блоков с
|
|
48
|
+
* теми же `list` / `category` / `random` / `id`, поэтому запрос один.
|
|
49
|
+
*/
|
|
50
|
+
list?: number
|
|
51
|
+
offset?: number
|
|
52
|
+
/** `use_random` провайдера (по умолчанию да). */
|
|
53
|
+
random?: boolean
|
|
54
|
+
/** Id ролика — провайдер подбирает моделей под ролик. */
|
|
55
|
+
id?: string
|
|
56
|
+
/** Без счётчиков онлайна в шапке (мини-блок videosection.com). */
|
|
57
|
+
hideCounts?: boolean
|
|
45
58
|
}>(), {
|
|
46
59
|
reserveTail: 0,
|
|
60
|
+
offset: 0,
|
|
61
|
+
random: true,
|
|
47
62
|
});
|
|
48
63
|
|
|
64
|
+
const NO_COUNTS = { total: 0, byLanguages: {} };
|
|
65
|
+
|
|
49
66
|
/**
|
|
50
67
|
* Клики и показы тумб блок отдаёт наружу: индексы он знает, а вот куда их
|
|
51
68
|
* отправлять — дело сайта (у статистики свой контекст страницы и устройства).
|
|
@@ -60,14 +77,16 @@ const webcamsConfig = computed(() => (useRuntimeConfig().public.webcamsConfig ||
|
|
|
60
77
|
const blockLimit = computed(() => props.limit || webcamsConfig.value.limit || WEBCAMS_DEFAULTS.limit);
|
|
61
78
|
|
|
62
79
|
const { items, counts, isEnabled } = await useFetchWebcams({
|
|
63
|
-
limit: blockLimit.value + props.reserveTail,
|
|
80
|
+
limit: props.list || blockLimit.value + props.reserveTail,
|
|
64
81
|
category: props.category,
|
|
82
|
+
random: props.random,
|
|
83
|
+
id: props.id,
|
|
65
84
|
// тумбы блока висят на странице долго — перепроверяем, кто ещё в эфире
|
|
66
85
|
keepAlive: true,
|
|
67
86
|
});
|
|
68
87
|
|
|
69
88
|
/** Хвост списка отдан странице (врезки в сетку) — блок берёт только свою голову. */
|
|
70
|
-
const blockItems = computed(() => items.value.slice(
|
|
89
|
+
const blockItems = computed(() => items.value.slice(props.offset, props.offset + blockLimit.value));
|
|
71
90
|
|
|
72
91
|
/**
|
|
73
92
|
* Колонок в сетке столько же, сколько в основном гриде сайта на этом
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Language } from '../../runtime';
|
|
2
|
+
import { AsyncData, getSelectedQuery } from '../../runtime';
|
|
3
|
+
import { sortModels } from '../../lib';
|
|
4
|
+
import { useRoute } from 'vue-router';
|
|
5
|
+
import type { IModelCard, PaginatedResponse } from '../../types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Выдача страницы поиска моделей `/search/{q}/models`: страница и сортировка из адреса (`?sort`, по умолчанию `rank`).
|
|
9
|
+
* Метод — `ModelsApiService.getModelsSearchFiltered(фраза)` (`lang, pagination, order`); ключ кэша — по фразе и query.
|
|
10
|
+
*/
|
|
11
|
+
export const useFetchModelsSearch = async (
|
|
12
|
+
apiMethod: (...args: any[]) => Promise<PaginatedResponse<IModelCard>>
|
|
13
|
+
) => {
|
|
14
|
+
const route = useRoute();
|
|
15
|
+
const mobile = !!useState<boolean>('isMobile').value;
|
|
16
|
+
const perPage = mobile ? 12 : 48;
|
|
17
|
+
const lang = useLang() as Language;
|
|
18
|
+
const slug = useSlug();
|
|
19
|
+
const stateKey = computed(() => `data-${AsyncData.ModelsSearch}-${slug.value}-${JSON.stringify(route.query)}-${lang}`);
|
|
20
|
+
|
|
21
|
+
const { data, status, error } = await useAsyncData<PaginatedResponse<IModelCard>>(
|
|
22
|
+
stateKey.value,
|
|
23
|
+
() => useApiFetcher<PaginatedResponse<IModelCard>>(
|
|
24
|
+
stateKey.value,
|
|
25
|
+
apiMethod,
|
|
26
|
+
lang,
|
|
27
|
+
{
|
|
28
|
+
page: Number(route.query['page']) || 1,
|
|
29
|
+
'per-page': perPage,
|
|
30
|
+
},
|
|
31
|
+
getSelectedQuery(route, sortModels) || 'rank',
|
|
32
|
+
)(),
|
|
33
|
+
{
|
|
34
|
+
watch: [() => JSON.stringify(route.query)],
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// за последней страницей API моделей отвечает ошибкой, а не пустой страницей — пагинации не на что увести
|
|
39
|
+
if ((Number(route.query['page']) || 1) > 1 && !data.value?.items?.length) {
|
|
40
|
+
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { data, status, error };
|
|
44
|
+
};
|
|
@@ -9,6 +9,7 @@ export const useFetchPlaylistsByNiche = async (apiMethod: (...args: any[]) => Pr
|
|
|
9
9
|
const mobile = !!useState<boolean>('isMobile').value;
|
|
10
10
|
const perPage = mobile ? 12 : 48;
|
|
11
11
|
const lang = useLang() as Language;
|
|
12
|
+
const defaultOrder = useAppConfig().defaultOrders.playlists;
|
|
12
13
|
const key = AsyncData.GetPlaylistsByNiche;
|
|
13
14
|
const stateKey = computed(() => `data-${key}-${JSON.stringify(route.query)}-${lang}`);
|
|
14
15
|
|
|
@@ -21,7 +22,7 @@ export const useFetchPlaylistsByNiche = async (apiMethod: (...args: any[]) => Pr
|
|
|
21
22
|
page: Number(route.query['page']) || 1,
|
|
22
23
|
'per-page': perPage,
|
|
23
24
|
},
|
|
24
|
-
getSelectedQuery(route, sortPlaylists) || '-views',
|
|
25
|
+
getSelectedQuery(route, sortPlaylists) || (defaultOrder ?? '-views') || undefined,
|
|
25
26
|
lang,
|
|
26
27
|
)(),
|
|
27
28
|
{
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import type { Language } from '../../runtime';
|
|
3
|
+
import { AsyncData } from '../../runtime';
|
|
4
|
+
import type { IRelatedPhrases } from '../../types';
|
|
5
|
+
|
|
6
|
+
/** Фразы, похожие на `query` (страницы поиска и тега), с весом `score`. */
|
|
7
|
+
export const useFetchRelatedSearchesByQuery = async (
|
|
8
|
+
apiMethod: (...args: any[]) => Promise<IRelatedPhrases>,
|
|
9
|
+
query: Ref<string>,
|
|
10
|
+
) => {
|
|
11
|
+
const lang = useLang() as Language;
|
|
12
|
+
const key = AsyncData.RelatedSearchesByQuery;
|
|
13
|
+
const stateKey = computed(() => `data-${key}-${query.value}-${lang}`);
|
|
14
|
+
|
|
15
|
+
const { data, status, error } = await useAsyncData<IRelatedPhrases>(
|
|
16
|
+
stateKey.value,
|
|
17
|
+
() => useApiFetcher<IRelatedPhrases>(
|
|
18
|
+
stateKey.value,
|
|
19
|
+
apiMethod,
|
|
20
|
+
lang,
|
|
21
|
+
query.value,
|
|
22
|
+
)()
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return { data, status, error };
|
|
26
|
+
};
|