itube-specs 0.0.1055 → 0.0.1057

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.
Files changed (50) hide show
  1. package/SITE_UPDATES.md +43 -0
  2. package/app.config.ts +46 -2
  3. package/assets/scss/base/_loading.scss +6 -4
  4. package/assets/scss/mixins/_surface.scss +3 -1
  5. package/assets/scss/themes/dark/_theme.scss +2 -0
  6. package/assets/scss/themes/light/_theme.scss +4 -0
  7. package/assets/scss/vars/_vars.scss +5 -0
  8. package/components/grid/models.vue +28 -4
  9. package/components/grid/videos.vue +64 -10
  10. package/components/ui/video-preview.vue +1 -0
  11. package/components/view/model-videos.vue +0 -1
  12. package/components/view/models.vue +3 -0
  13. package/components/view/video.vue +35 -17
  14. package/components/view/videos.vue +19 -0
  15. package/components/webcam/main.vue +22 -3
  16. package/composables/fetch/use-fetch-models-search.ts +44 -0
  17. package/composables/fetch/use-fetch-playlists-by-niche.ts +2 -1
  18. package/composables/fetch/use-fetch-related-searches-by-query.ts +26 -0
  19. package/composables/fetch/use-fetch-top-categories.ts +22 -0
  20. package/composables/fetch/use-fetch-videos-by-categories.ts +2 -1
  21. package/composables/fetch/use-fetch-videos-by-channel.ts +2 -1
  22. package/composables/fetch/use-fetch-videos-by-model.ts +2 -1
  23. package/composables/fetch/use-fetch-videos-by-tag.ts +2 -1
  24. package/composables/fetch/use-fetch-webcams.ts +8 -3
  25. package/composables/fetch/use-search-related-phrases.ts +22 -0
  26. package/composables/page/use-category-page.ts +5 -9
  27. package/composables/page/use-search-page.ts +1 -1
  28. package/composables/use-related-phrases.ts +56 -0
  29. package/package.json +1 -1
  30. package/pages/categories/[slug].vue +2 -0
  31. package/pages/models/[slug]/channel/[channel].vue +0 -3
  32. package/pages/models/[slug]/index.vue +0 -3
  33. package/pages/models/[slug]/tag/[tag].vue +0 -3
  34. package/pages/models/letter/[slug].vue +3 -10
  35. package/pages/playlists/[playlistId]/[slug].vue +3 -6
  36. package/pages/search/[slug]/index.vue +3 -4
  37. package/pages/search/[slug]/models.vue +1 -5
  38. package/pages/tag/[tag].vue +22 -1
  39. package/pages/videos/[slug].vue +10 -5
  40. package/runtime/constants/async-data.ts +3 -0
  41. package/runtime/constants/webcams.ts +13 -0
  42. package/runtime/index.ts +1 -0
  43. package/runtime/utils/__tests__/seeded-shuffle.test.ts +27 -0
  44. package/runtime/utils/seeded-shuffle.ts +20 -0
  45. package/server/api/categories/get-top.get.ts +11 -0
  46. package/server/api/search/related-searches.get.ts +8 -0
  47. package/services/api/categories.service.ts +20 -0
  48. package/services/api/search.service.ts +27 -1
  49. package/utils/webcams/__tests__/insert-positions.test.ts +21 -1
  50. 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
- /** Сколько связанных поисковых фраз (по убыванию веса) показывать на странице категории, `null` — все, что пришли. */
18
- categoryRelatedSearchLimit: null as number | null,
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
  /** Карусель моделей под похожими. */
@@ -1,14 +1,16 @@
1
1
  @use 'vars' as *;
2
2
 
3
- // Состояние загрузки блока: заблюренная подложка со спиннером поверх содержимого (::before/::after).
3
+ // Состояние загрузки блока: заблюренная и приглушённая подложка со спиннером поверх содержимого (::before/::after).
4
+ // Цвета — var(--loading-bg / --loading-icon, токен темы): тёмная подложка (surface overlay) или блок перекрывают их сами.
4
5
  ._loading {
5
6
  position: relative;
6
7
 
7
8
  &::before {
8
9
  position: absolute;
9
- inset: 0;
10
+ inset: -$blur-loading-spill;
10
11
  z-index: $z-index-loader;
11
- backdrop-filter: blur(4px);
12
+ background-color: var(--loading-bg, #{$loading-bg});
13
+ backdrop-filter: blur($blur-loading);
12
14
  content: '';
13
15
  }
14
16
 
@@ -21,7 +23,7 @@
21
23
  display: block;
22
24
  width: var(--size, 64px);
23
25
  height: var(--size, 64px);
24
- background-color: $loading-icon;
26
+ background-color: var(--loading-icon, #{$loading-icon});
25
27
  content: '';
26
28
  mask-position: center;
27
29
  mask-size: contain;
@@ -55,7 +55,9 @@ $surface-roles: (
55
55
  accent: (--select-count-bg--active,),
56
56
  accent-text: (--select-count-text--active,),
57
57
  accent-bg-subtle: (--select-bg--active,),
58
- accent-border: (--select-border--active,)
58
+ accent-border: (--select-border--active,),
59
+ loading-bg: (--loading-bg,),
60
+ loading-icon: (--loading-icon,)
59
61
  );
60
62
 
61
63
  @mixin surface($name) {
@@ -18,6 +18,8 @@ $link-text: $color-white;
18
18
 
19
19
  // ─── Loading ───
20
20
  $loading-icon: $color-white;
21
+ $loading-bg: transparent;
22
+ $video-preview-loading-bg: transparent;
21
23
 
22
24
  // ─── Cards (shared) ───
23
25
  $card-ring-shadow--hover: none;
@@ -29,6 +29,8 @@ $link-text: $color-indigo-600;
29
29
 
30
30
  // ─── Loading ───
31
31
  $loading-icon: $color-indigo-600;
32
+ $loading-bg: $color-white-75;
33
+ $video-preview-loading-bg: transparent; // поверх постера не отбеливаем — иначе карточка вспыхивает на ховере
32
34
 
33
35
  // ─── Cards (shared) ───
34
36
  $card-ring-shadow--hover: 0 0 0 2px $main-bg, 0 0 0 3px $color-gray-400;
@@ -140,6 +142,8 @@ $surfaces: (
140
142
  accent-text: $color-white,
141
143
  accent-bg-subtle: color-mix(in oklch, #{$color-indigo-500} 10%, transparent),
142
144
  accent-border: $color-indigo-500,
145
+ loading-bg: $mono-black-50,
146
+ loading-icon: $color-white,
143
147
  ),
144
148
  );
145
149
 
@@ -14,3 +14,8 @@ $radius-full: 9999px;
14
14
  // ─── Blur ───
15
15
  $blur-sm: 8px;
16
16
  $blur-md: 12px;
17
+ $blur-loading: 4px;
18
+
19
+ // Запас подложки лоадера за край блока: backdrop-filter тянет крайние пиксели до границы области,
20
+ // и без запаса скругления крайних карточек под блюром выглядят прямыми.
21
+ $blur-loading-spill: $blur-loading * 3;
@@ -18,30 +18,48 @@
18
18
  v-if="webcams"
19
19
  :after-rows="webcams.afterRows"
20
20
  >
21
- <LazyWebcamMain hydrate-on-visible/>
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 { webcamInsertPositions } from '../../utils/webcams/insert-positions';
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 ? appConfig.gridWebcams?.videos : null;
170
- const ownInserts = webcams ? await useWebcamsShare(webcams.inserts) : null;
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 = webcamInsertPositions(
179
- props.insertSeed || props.items[0]?.guid || '',
180
- props.items.length,
181
- models.length,
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
- props.items.forEach((video, index) => {
188
- if (positions[inserted] === index) {
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;
@@ -42,6 +42,7 @@ onMounted(() => {
42
42
  <style lang="scss">
43
43
  .ui-video-preview {
44
44
  --size: 40px;
45
+ --loading-bg: #{$video-preview-loading-bg};
45
46
 
46
47
  position: absolute;
47
48
  inset: 0;
@@ -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
- const webcamInserts = videoPage.webcamsBlock
163
- ? await useWebcamsShare(videoPage.webcamInserts)
164
- : (await useFetchWebcams({ clientOnly: true })).items;
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 refererChips = computed<IChipsItem[]>(() => (Array.isArray(refererPhrases.value) ? refererPhrases.value : [])
171
- .map(item => ({ title: item, value: item, prefix: 'search' })));
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 recentSearches = computed(() => {
209
- if (props.recent && typeof props.recent === 'object' && Object.keys(props.recent).length === 0) {
210
- return [];
211
- }
212
- return props.recent?.map((item: string) => ({
213
- value: item,
214
- title: item,
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(0, blockLimit.value));
89
+ const blockItems = computed(() => items.value.slice(props.offset, props.offset + blockLimit.value));
71
90
 
72
91
  /**
73
92
  * Колонок в сетке столько же, сколько в основном гриде сайта на этом