itube-specs 0.0.1058 → 0.0.1060
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 +27 -0
- package/app.config.ts +6 -2
- package/components/grid/insert.vue +6 -3
- package/components/grid/models.vue +1 -0
- package/components/grid/videos.vue +12 -6
- package/components/view/video.vue +33 -12
- package/components/view/videos.vue +4 -1
- package/components/webcam/main.vue +4 -1
- package/composables/use-grid-row-after.ts +44 -10
- package/package.json +1 -1
- package/runtime/index.ts +1 -0
- package/runtime/utils/__tests__/video-data-mark-top-categories.test.ts +19 -0
- package/runtime/utils/video-data-mark-top-categories.ts +21 -0
- package/server/api/videos/get-video-by-id/[id].get.ts +4 -3
- package/server/api/videos/get-video-by-url/[url].get.ts +4 -3
- package/server/utils/top-category-names.ts +14 -0
- package/types/raw/raw-video-data.d.ts +3 -1
- package/types/video-data.d.ts +2 -0
package/SITE_UPDATES.md
CHANGED
|
@@ -11,6 +11,33 @@
|
|
|
11
11
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
12
12
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
13
13
|
|
|
14
|
+
## 0.0.1060 — 2026-11-02
|
|
15
|
+
|
|
16
|
+
- **Сайтам со своими `assets/scss/vars` (hdsex-remaster и др.), срочно:** с 0.0.1059 (лоадер, `0d412182`) без новых
|
|
17
|
+
токенов сборка падает на `Undefined variable`. В `_vars.scss`: `$blur-loading: 4px;` и
|
|
18
|
+
`$blur-loading-spill: $blur-loading * 3;`. В теме: `$loading-bg` (тёмная — `transparent`, светлая —
|
|
19
|
+
`$color-white-75`) и `$video-preview-loading-bg: transparent;`. Образец — hdsex-remaster.
|
|
20
|
+
|
|
21
|
+
- **Всем — к сведению:** в строке чипсов над плеером — только топ-категории ролика (`categories[].isTop`, ставит BFF
|
|
22
|
+
видео по закешированному `/v1/categories/top`, к бекенду — раз в 10 минут на домен/язык/нишу). Нетоповые остаются в
|
|
23
|
+
блоке под описанием и не возвращаются в строку тегом. Топ-список не пришёл — как раньше, все категории. Делать ничего
|
|
24
|
+
не надо.
|
|
25
|
+
|
|
26
|
+
## 0.0.1059 — 2026-10-30
|
|
27
|
+
|
|
28
|
+
- **Всем, срочно к сведению:** с 0.0.1056 блок Live Cams и врезки вебкамов пропали из сеток видео на страницах, которые
|
|
29
|
+
не передают `webcamsQuery` (канал, поиск, листинги на `ViewVideos` без запроса): проп был `{…} | false`, и Vue приводил
|
|
30
|
+
отсутствующий булев проп к `false` = «без вебкамов». Починено. Сайтам, где сетке передавали `:webcams-query="false"`,
|
|
31
|
+
— заменить на `no-webcams` (новый булев проп `GridVideos` / `ViewVideos`), иначе вебкамы там снова появятся.
|
|
32
|
+
|
|
33
|
+
- **Всем — к сведению:** `GridInsert` / `useGridRowAfter` умеют `afterCards` — после строки с N-й карточкой (без дыр,
|
|
34
|
+
вебкам-тумбы с `data-grid-extra` не в счёт; карточек меньше — за последней строкой); пересчёт и на перестановку
|
|
35
|
+
соседних вставок (раньше реклама могла сдвинуть блок, а он не замечал). Новые ключи: `gridWebcams.videos.afterCards`,
|
|
36
|
+
`gridWebcams.models.afterCards` (важнее `afterRows`), `videoPage.webcamsInRelatedAfter` — блок Live Cams внутри
|
|
37
|
+
сетки похожих. Дефолты — как было, делать ничего не надо; videosection-redesign ставит 14 / 10 / 14.
|
|
38
|
+
- **Всем — к сведению:** у `WebcamMain` проп `fullRows` (по умолчанию `true` — только полные строки, как было);
|
|
39
|
+
`false` — все модели, последняя строка может быть неполной.
|
|
40
|
+
|
|
14
41
|
## 0.0.1058 — 2026-10-30
|
|
15
42
|
|
|
16
43
|
- **Всем — к сведению:** новый ключ `appConfig.channelTagsSource` (по умолчанию `'popular'` — как было, популярные теги
|
package/app.config.ts
CHANGED
|
@@ -61,8 +61,9 @@ export default defineAppConfig({
|
|
|
61
61
|
* карточек и сколько врезок вебкам-тумб (хвост того же списка) в сетке видео листинга (не похожие, не плейлист,
|
|
62
62
|
* не когда страница передала врезки сама); `models` — в основной сетке моделей (`priority`, не футер). */
|
|
63
63
|
gridWebcams: {
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
/** `afterCards` — после строки с N-й карточкой (карточек меньше — в конец сетки); задан — важнее `afterRows`. */
|
|
65
|
+
videos: null as { afterRows: number, afterCards?: number, inserts: number } | null,
|
|
66
|
+
models: null as { afterRows: number, afterCards?: number } | null,
|
|
66
67
|
/** Раскладка videosection.com (`WEBCAMS_LEGACY_LAYOUT`): список 42 на страницу, блок — модели 0–11, врезки в сетку
|
|
67
68
|
* видео — 21–24 после ¼, ½, ¾ сетки и за 4 карточки до конца (`inserts` из `videos` не используется), мини-блок
|
|
68
69
|
* после сетки видео и моделей — 25–36. `false` — раскладка слоя: блок 12 + врезки из хвоста на случайных местах. */
|
|
@@ -86,6 +87,9 @@ export default defineAppConfig({
|
|
|
86
87
|
relatedModels: true,
|
|
87
88
|
/** Блок Live Cams после похожих (при `FeatureWebcamsEnabled`); врезки в похожие берут хвост того же списка. */
|
|
88
89
|
webcamsBlock: false,
|
|
90
|
+
/** Блок Live Cams внутри сетки похожих — после строки с N-й карточкой (похожих меньше — в конце сетки);
|
|
91
|
+
* `null` — после секции похожих. */
|
|
92
|
+
webcamsInRelatedAfter: null as number | null,
|
|
89
93
|
/** Сколько моделей из хвоста списка уходит во врезки похожих, когда блок Live Cams включён. */
|
|
90
94
|
webcamInserts: 8,
|
|
91
95
|
/** С 1024px прятать чипсы моделей над плеером, у которых есть блок в боковой колонке (не дублировать ссылку). */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<!-- Полноширинная вставка в CSS-сетку карточек (`GridVideos`, `GridModels`): встаёт после N-й строки карточек
|
|
2
|
-
|
|
2
|
+
(`afterRows`) или после строки с N-й карточкой (`afterCards`, карточек меньше — в конец) при любом числе колонок
|
|
3
|
+
(`useGridRowAfter`), пустая места не занимает. Класть прямым потомком сетки — через её слоты. -->
|
|
3
4
|
<template>
|
|
4
5
|
<div
|
|
5
6
|
ref="root"
|
|
@@ -12,12 +13,14 @@
|
|
|
12
13
|
<script setup lang="ts">
|
|
13
14
|
const props = defineProps<{
|
|
14
15
|
/** После какой строки карточек встать. */
|
|
15
|
-
afterRows
|
|
16
|
+
afterRows?: number
|
|
17
|
+
/** После строки, в которой лежит N-я карточка (без дыр перед блоком); задан — важнее `afterRows`. */
|
|
18
|
+
afterCards?: number
|
|
16
19
|
}>();
|
|
17
20
|
|
|
18
21
|
const root = ref<HTMLElement | null>(null);
|
|
19
22
|
|
|
20
|
-
useGridRowAfter(root, () => props.afterRows);
|
|
23
|
+
useGridRowAfter(root, () => (props.afterCards ? { cards: props.afterCards } : { rows: props.afterRows ?? 0 }));
|
|
21
24
|
</script>
|
|
22
25
|
|
|
23
26
|
<style lang="scss">
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
v-if="cell.webcam"
|
|
22
22
|
:model="cell.webcam"
|
|
23
23
|
class="grid-videos__webcam"
|
|
24
|
+
data-grid-extra
|
|
24
25
|
@click="onInsertClick(cell)"
|
|
25
26
|
@poster="element => onInsertView(cell, element)"
|
|
26
27
|
/>
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
<GridInsert
|
|
45
46
|
v-if="webcams"
|
|
46
47
|
:after-rows="webcams.afterRows"
|
|
48
|
+
:after-cards="webcams.afterCards"
|
|
47
49
|
>
|
|
48
50
|
<LazyWebcamMain
|
|
49
51
|
v-if="legacyList"
|
|
@@ -64,6 +66,7 @@
|
|
|
64
66
|
v-if="cell.webcam"
|
|
65
67
|
:model="cell.webcam"
|
|
66
68
|
class="grid-videos__webcam"
|
|
69
|
+
data-grid-extra
|
|
67
70
|
@click="onInsertClick(cell)"
|
|
68
71
|
@poster="element => onInsertView(cell, element)"
|
|
69
72
|
/>
|
|
@@ -90,6 +93,7 @@
|
|
|
90
93
|
v-if="cell.webcam"
|
|
91
94
|
:model="cell.webcam"
|
|
92
95
|
class="grid-videos__webcam"
|
|
96
|
+
data-grid-extra
|
|
93
97
|
@click="onInsertClick(cell)"
|
|
94
98
|
@poster="element => onInsertView(cell, element)"
|
|
95
99
|
/>
|
|
@@ -159,9 +163,11 @@ const props = defineProps<{
|
|
|
159
163
|
insertSeed?: string
|
|
160
164
|
/**
|
|
161
165
|
* Выборка вебкамов листинга при `gridWebcams.legacy`: категория провайдера, `random` и размер списка (по умолчанию
|
|
162
|
-
* `WEBCAMS_LEGACY_LAYOUT.list`, случайные).
|
|
166
|
+
* `WEBCAMS_LEGACY_LAYOUT.list`, случайные). Только объект: булев тип в пропе Vue приводит отсутствующий проп к `false`.
|
|
163
167
|
*/
|
|
164
|
-
webcamsQuery?: { category?: string, random?: boolean, list?: number }
|
|
168
|
+
webcamsQuery?: { category?: string, random?: boolean, list?: number }
|
|
169
|
+
/** Без блока Live Cams и врезок (в любой раскладке). */
|
|
170
|
+
noWebcams?: boolean
|
|
165
171
|
}>();
|
|
166
172
|
|
|
167
173
|
/** Клики и показы врезанных тумб уходят наружу — статистику отправляет сайт. */
|
|
@@ -189,16 +195,16 @@ const cardTriggerClass = appConfig.cardTriggerClass || undefined;
|
|
|
189
195
|
const legacy = !!appConfig.gridWebcams?.legacy;
|
|
190
196
|
|
|
191
197
|
/** Блок Live Cams и свои врезки — у листингов (похожие и плейлист без них, страница видео передаёт врезки сама). */
|
|
192
|
-
const webcams = !props.inserts && !props.related && !props.playlist && props.
|
|
198
|
+
const webcams = !props.inserts && !props.related && !props.playlist && !props.noWebcams
|
|
193
199
|
? appConfig.gridWebcams?.videos
|
|
194
200
|
: null;
|
|
195
201
|
|
|
196
202
|
/** Общий список страницы в раскладке videosection.com (`gridWebcams.legacy`): блок, врезки и мини-блок режут его по индексам. */
|
|
197
203
|
const legacyList = webcams && legacy
|
|
198
204
|
? {
|
|
199
|
-
list:
|
|
200
|
-
category:
|
|
201
|
-
random: props.webcamsQuery
|
|
205
|
+
list: props.webcamsQuery?.list || WEBCAMS_LEGACY_LAYOUT.list,
|
|
206
|
+
category: props.webcamsQuery?.category || undefined,
|
|
207
|
+
random: props.webcamsQuery?.random !== false,
|
|
202
208
|
}
|
|
203
209
|
: null;
|
|
204
210
|
|
|
@@ -81,6 +81,20 @@
|
|
|
81
81
|
<template #grid-end>
|
|
82
82
|
<AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
|
|
83
83
|
</template>
|
|
84
|
+
|
|
85
|
+
<GridInsert
|
|
86
|
+
v-if="videoPage.webcamsBlock && videoPage.webcamsInRelatedAfter"
|
|
87
|
+
:after-cards="videoPage.webcamsInRelatedAfter"
|
|
88
|
+
>
|
|
89
|
+
<WebcamMain
|
|
90
|
+
v-if="legacyWebcams"
|
|
91
|
+
v-bind="legacyWebcams"
|
|
92
|
+
/>
|
|
93
|
+
<WebcamMain
|
|
94
|
+
v-else
|
|
95
|
+
:reserve-tail="videoPage.webcamInserts"
|
|
96
|
+
/>
|
|
97
|
+
</GridInsert>
|
|
84
98
|
</LazyGridVideos>
|
|
85
99
|
</section>
|
|
86
100
|
|
|
@@ -102,16 +116,18 @@
|
|
|
102
116
|
hydrate-on-visible
|
|
103
117
|
/>
|
|
104
118
|
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
<template v-if="videoPage.webcamsBlock && !videoPage.webcamsInRelatedAfter">
|
|
120
|
+
<WebcamMain
|
|
121
|
+
v-if="legacyWebcams"
|
|
122
|
+
v-bind="legacyWebcams"
|
|
123
|
+
class="_mt-24"
|
|
124
|
+
/>
|
|
125
|
+
<WebcamMain
|
|
126
|
+
v-else
|
|
127
|
+
:reserve-tail="videoPage.webcamInserts"
|
|
128
|
+
class="_mt-24"
|
|
129
|
+
/>
|
|
130
|
+
</template>
|
|
115
131
|
|
|
116
132
|
<ViewVideoAfterRelatedSlot :data="data"/>
|
|
117
133
|
|
|
@@ -218,6 +234,11 @@ const categoriesChips = computed((): IChipsItem[] => props.data.categories && pr
|
|
|
218
234
|
prefix: 'categories',
|
|
219
235
|
})));
|
|
220
236
|
|
|
237
|
+
// Над плеером — только топ-категории (`isTop` ставит BFF); без пометки (топ-список не пришёл) — все.
|
|
238
|
+
// Теги, совпавшие с нетоповой категорией, всё равно отсекаются по полному списку ниже.
|
|
239
|
+
const topCategoriesChips = computed((): IChipsItem[] => (categoriesChips.value ?? [])
|
|
240
|
+
.filter((_, index) => props.data.categories[index]?.isTop !== false));
|
|
241
|
+
|
|
221
242
|
const categoriesTitles = computed<string[]>(() => props.data.categories && props.data.categories?.map(item => item.title?.replace(/\s+/g, '').toLowerCase() ?? ''));
|
|
222
243
|
|
|
223
244
|
const withoutCategoryTitles = (chips: IChipsItem[]) => (categoriesTitles.value?.length
|
|
@@ -242,9 +263,9 @@ const topChips = computed(() => {
|
|
|
242
263
|
prefix: 'search',
|
|
243
264
|
} as IChipsItem));
|
|
244
265
|
} else if (tagGroupChips.value.length) {
|
|
245
|
-
return [...
|
|
266
|
+
return [...topCategoriesChips.value, ...withoutCategoryTitles(tagGroupChips.value)];
|
|
246
267
|
} else if (categoriesChips.value || filterTagChips.value) {
|
|
247
|
-
return [...
|
|
268
|
+
return [...topCategoriesChips.value, ...filterTagChips.value || []];
|
|
248
269
|
}
|
|
249
270
|
return null;
|
|
250
271
|
});
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
v-if="data && data.items && data.items?.length > 0"
|
|
32
32
|
:items="data.items"
|
|
33
33
|
:webcams-query="webcamsQuery"
|
|
34
|
+
:no-webcams="noWebcams"
|
|
34
35
|
:class="{'_loading': status === 'pending'}"
|
|
35
36
|
class="view-videos__grid"
|
|
36
37
|
id="anchor"
|
|
@@ -118,7 +119,9 @@ const props = defineProps<{
|
|
|
118
119
|
/** Чипсы поисковых фраз во второй строке грида, как на странице категории. */
|
|
119
120
|
relatedSearch?: IChipsItem[]
|
|
120
121
|
/** Выборка вебкамов сетки (`GridVideos`). */
|
|
121
|
-
webcamsQuery?: { category?: string, random?: boolean, list?: number }
|
|
122
|
+
webcamsQuery?: { category?: string, random?: boolean, list?: number }
|
|
123
|
+
/** Сетка без вебкамов (`GridVideos`). */
|
|
124
|
+
noWebcams?: boolean
|
|
122
125
|
}>();
|
|
123
126
|
|
|
124
127
|
const route = useRoute();
|
|
@@ -55,10 +55,13 @@ const props = withDefaults(defineProps<{
|
|
|
55
55
|
id?: string
|
|
56
56
|
/** Без счётчиков онлайна в шапке (мини-блок videosection.com). */
|
|
57
57
|
hideCounts?: boolean
|
|
58
|
+
/** Только полные строки (по умолчанию); `false` — все модели, последняя строка может быть неполной. */
|
|
59
|
+
fullRows?: boolean
|
|
58
60
|
}>(), {
|
|
59
61
|
reserveTail: 0,
|
|
60
62
|
offset: 0,
|
|
61
63
|
random: true,
|
|
64
|
+
fullRows: true,
|
|
62
65
|
});
|
|
63
66
|
|
|
64
67
|
const NO_COUNTS = { total: 0, byLanguages: {} };
|
|
@@ -103,7 +106,7 @@ const columns = ref(0);
|
|
|
103
106
|
*/
|
|
104
107
|
const visibleItems = computed<IStripcashModel[]>(() => {
|
|
105
108
|
const cols = columns.value;
|
|
106
|
-
if (!cols) return blockItems.value;
|
|
109
|
+
if (!cols || !props.fullRows) return blockItems.value;
|
|
107
110
|
|
|
108
111
|
return blockItems.value.slice(0, Math.floor(blockItems.value.length / cols) * cols);
|
|
109
112
|
});
|
|
@@ -1,35 +1,61 @@
|
|
|
1
1
|
import { useDebounceFn } from '@vueuse/core';
|
|
2
2
|
|
|
3
|
+
/** Где встать: после N строк карточек или после строки, в которой лежит N-я карточка. */
|
|
4
|
+
export type GridInsertPlace = { rows: number } | { cards: number };
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* Ставит полноширинный блок CSS-сетки после N-й строки карточек — при любом числе колонок.
|
|
5
8
|
*
|
|
6
9
|
* Считаем по карточкам, а не по строкам сетки: в сетку попадают и полноширинные врезки (карусель
|
|
7
10
|
* моделей, блок вебкамов, реклама), они занимают строку целиком. Карточку отличаем геометрически —
|
|
8
|
-
* шириной в одну
|
|
11
|
+
* шириной в одну колонку; скрытые (`display: none`) не считаем.
|
|
12
|
+
*
|
|
13
|
+
* `{ cards: N }` — после строки с N-й карточкой (без дыр в строке перед блоком; врезки с `data-grid-extra` не в счёт);
|
|
14
|
+
* карточек меньше — сразу за строкой последней. `{ rows: N }` — карточек до нужной строки не хватает — блок остаётся
|
|
15
|
+
* на своём месте в DOM.
|
|
9
16
|
*/
|
|
10
|
-
export function placeAfterCardRows(el: HTMLElement | null,
|
|
17
|
+
export function placeAfterCardRows(el: HTMLElement | null, place: number | GridInsertPlace) {
|
|
11
18
|
const grid = el?.parentElement;
|
|
12
19
|
if (!el || !grid) return;
|
|
13
20
|
|
|
14
21
|
const columns = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean);
|
|
15
22
|
const columnWidth = Number.parseFloat(columns[0]) || 0;
|
|
16
23
|
|
|
17
|
-
const siblings = Array.from(grid.children).filter(item => item !== el
|
|
24
|
+
const siblings = (Array.from(grid.children) as HTMLElement[]).filter(item => item !== el && item.offsetWidth > 0);
|
|
18
25
|
const cards = siblings.filter(item => item.offsetWidth <= columnWidth * 1.5);
|
|
26
|
+
const setRow = (row: string) => {
|
|
27
|
+
if (el.style.gridRow !== row) el.style.gridRow = row;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (typeof place === 'object' && 'cards' in place) {
|
|
31
|
+
// N-я карточка выдачи (врезки с `data-grid-extra` — вебкам-тумбы — не в счёт) или последняя, если их меньше;
|
|
32
|
+
// блок — сразу за её строкой
|
|
33
|
+
const counted = cards.filter(item => !item.hasAttribute('data-grid-extra'));
|
|
34
|
+
const anchor = counted[place.cards - 1] || counted.at(-1);
|
|
35
|
+
const rowsUpToAnchor = anchor
|
|
36
|
+
? new Set(siblings.filter(item => item.offsetTop <= anchor.offsetTop).map(item => item.offsetTop)).size
|
|
37
|
+
: 0;
|
|
38
|
+
|
|
39
|
+
setRow(anchor ? String(rowsUpToAnchor + 1) : '');
|
|
40
|
+
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const target = cards[(typeof place === 'number' ? place : place.rows) * columns.length];
|
|
19
45
|
|
|
20
|
-
const target = cards[rows * columns.length];
|
|
21
46
|
if (!target) {
|
|
22
|
-
|
|
47
|
+
setRow('');
|
|
23
48
|
|
|
24
49
|
return;
|
|
25
50
|
}
|
|
26
51
|
|
|
27
52
|
const rowsAbove = new Set(siblings.filter(item => item.offsetTop < target.offsetTop).map(item => item.offsetTop));
|
|
28
|
-
|
|
53
|
+
setRow(String(rowsAbove.size + 1));
|
|
29
54
|
}
|
|
30
55
|
|
|
31
|
-
/** То же для компонента: пересчёт на resize (меняются колонки)
|
|
32
|
-
|
|
56
|
+
/** То же для компонента: пересчёт на resize (меняются колонки), на доезд карточек (ленивая гидрация) и на перестановку
|
|
57
|
+
* соседних вставок. */
|
|
58
|
+
export const useGridRowAfter = (el: Ref<HTMLElement | null>, rows: () => number | GridInsertPlace) => {
|
|
33
59
|
const place = () => placeAfterCardRows(el.value, rows());
|
|
34
60
|
|
|
35
61
|
onMounted(() => {
|
|
@@ -37,11 +63,19 @@ export const useGridRowAfter = (el: Ref<HTMLElement | null>, rows: () => number)
|
|
|
37
63
|
if (!grid) return;
|
|
38
64
|
|
|
39
65
|
const replace = useDebounceFn(place, 200);
|
|
40
|
-
|
|
66
|
+
// соседние вставки (реклама, другие блоки) тоже переставляют себя через style — пересчитываемся вслед за ними;
|
|
67
|
+
// style внутри карточек (превью и пр.) не интересен — только у прямых детей сетки, кроме себя
|
|
68
|
+
const observer = new MutationObserver((mutations) => {
|
|
69
|
+
if (mutations.some(item => (item.type === 'childList'
|
|
70
|
+
? item.target === grid
|
|
71
|
+
: item.target.parentElement === grid && item.target !== el.value))) {
|
|
72
|
+
replace();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
41
75
|
|
|
42
76
|
place();
|
|
43
77
|
window.addEventListener('resize', replace, { passive: true });
|
|
44
|
-
observer.observe(grid, { childList: true });
|
|
78
|
+
observer.observe(grid, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
|
|
45
79
|
|
|
46
80
|
onBeforeUnmount(() => {
|
|
47
81
|
window.removeEventListener('resize', replace);
|
package/package.json
CHANGED
package/runtime/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ export * from './utils/cleaners/clean-video-card';
|
|
|
46
46
|
export * from './utils/cleaners/clean-model-info';
|
|
47
47
|
export * from './utils/cleaners/clean-suggest-models';
|
|
48
48
|
export * from './utils/video-data-add-model-portraits';
|
|
49
|
+
export * from './utils/video-data-mark-top-categories';
|
|
49
50
|
export * from './utils/converters/convert-categories-to-footer';
|
|
50
51
|
export * from './utils/converters/convert-categories-to-chips';
|
|
51
52
|
export * from './utils/converters/convert-tag-group-item-to-chips';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { IRawVideoData } from '../../../types';
|
|
3
|
+
import { videoDataMarkTopCategories } from '../video-data-mark-top-categories';
|
|
4
|
+
|
|
5
|
+
const video = {
|
|
6
|
+
categories: [{ name: 'cartoon', title: 'Cartoon' }, { name: '3d', title: '3D' }, { name: 'Masturbation', title: 'Masturbation' }],
|
|
7
|
+
} as IRawVideoData;
|
|
8
|
+
|
|
9
|
+
describe('videoDataMarkTopCategories', () => {
|
|
10
|
+
it('marks categories from the top list, ignoring case and edge spaces', () => {
|
|
11
|
+
const result = videoDataMarkTopCategories(video, ['cartoon', 'masturbation ']);
|
|
12
|
+
|
|
13
|
+
expect(result.categories.map(category => category.isTop)).toEqual([true, false, true]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('leaves the video untouched without the top list', () => {
|
|
17
|
+
expect(videoDataMarkTopCategories(video, null)).toBe(video);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { IRawVideoData } from '../../types';
|
|
2
|
+
|
|
3
|
+
const normalize = (name: string) => name.trim().toLowerCase();
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Помечает категории ролика `isTop` по списку топ-категорий: в строку над плеером идут только топовые.
|
|
7
|
+
* Списка нет (ручка упала) — ролик без пометок, строка покажет все категории, как раньше.
|
|
8
|
+
* Имена сравниваются без регистра и краевых пробелов: в API встречается `masturbation `.
|
|
9
|
+
* @param videoData - сырые данные видео
|
|
10
|
+
* @param topNames - имена топ-категорий
|
|
11
|
+
*/
|
|
12
|
+
export function videoDataMarkTopCategories(videoData: IRawVideoData, topNames: string[] | null): IRawVideoData {
|
|
13
|
+
if (!videoData?.categories?.length || !topNames) return videoData;
|
|
14
|
+
|
|
15
|
+
const top = new Set(topNames.map(normalize));
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
...videoData,
|
|
19
|
+
categories: videoData.categories.map(category => ({ ...category, isTop: top.has(normalize(category.name)) })),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
|
|
2
|
-
import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
|
|
2
|
+
import { cleanVideoData, videoDataAddModelPortraits, videoDataMarkTopCategories, getUrlWithProxiedParams } from '../../../../runtime';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
5
5
|
const theme = useRuntimeConfig(event).public.theme;
|
|
@@ -17,15 +17,16 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
|
17
17
|
const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v2/video/url/${id}`, event);
|
|
18
18
|
const modelsUrl = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-video-id/${id}`, event);
|
|
19
19
|
|
|
20
|
-
const [videoRaw, modelsRaw] = await Promise.all([
|
|
20
|
+
const [videoRaw, modelsRaw, topCategoryNames] = await Promise.all([
|
|
21
21
|
apiFetch(videoUrl, {
|
|
22
22
|
method: 'GET',
|
|
23
23
|
headers: clientHeaders,
|
|
24
24
|
}),
|
|
25
25
|
apiFetch(modelsUrl, { method: 'GET', headers: clientHeaders }).catch(() => null),
|
|
26
|
+
getTopCategoryNames(event),
|
|
26
27
|
]);
|
|
27
28
|
|
|
28
|
-
const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
|
|
29
|
+
const videoData = videoDataMarkTopCategories(withDemoFallbacks(videoRaw.data as IRawVideoData), topCategoryNames);
|
|
29
30
|
const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
|
|
30
31
|
|
|
31
32
|
return cleanVideoData(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
|
|
1
|
+
import { cleanVideoData, videoDataAddModelPortraits, videoDataMarkTopCategories, getUrlWithProxiedParams } from '../../../../runtime';
|
|
2
2
|
import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
@@ -15,15 +15,16 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
|
15
15
|
const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v2/video/url/${url}`, event);
|
|
16
16
|
const modelsUrl = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-video-url/${url}`, event);
|
|
17
17
|
|
|
18
|
-
const [videoRaw, modelsRaw] = await Promise.all([
|
|
18
|
+
const [videoRaw, modelsRaw, topCategoryNames] = await Promise.all([
|
|
19
19
|
apiFetch(videoUrl, {
|
|
20
20
|
method: 'GET',
|
|
21
21
|
headers: clientHeaders,
|
|
22
22
|
}),
|
|
23
23
|
apiFetch(modelsUrl, { method: 'GET', headers: clientHeaders }).catch(() => null),
|
|
24
|
+
getTopCategoryNames(event),
|
|
24
25
|
]);
|
|
25
26
|
|
|
26
|
-
const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
|
|
27
|
+
const videoData = videoDataMarkTopCategories(withDemoFallbacks(videoRaw.data as IRawVideoData), topCategoryNames);
|
|
27
28
|
const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
|
|
28
29
|
|
|
29
30
|
return cleanVideoData(
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
import type { IRawCategoryCard } from '../../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Имена топ-категорий из кеша BFF (`cachedApiFetch`, 10 минут на домен + язык + нишу): к бекенду ходим
|
|
6
|
+
* раз в TTL, а не на каждый ролик. Ошибка ручки — `null`, ролик от этого не падает.
|
|
7
|
+
*/
|
|
8
|
+
export async function getTopCategoryNames(event: H3Event): Promise<string[] | null> {
|
|
9
|
+
const url = `${process.env.NUXT_PUBLIC_API_DOMAIN}/v1/categories/top`;
|
|
10
|
+
|
|
11
|
+
return cachedApiFetch<Array<IRawCategoryCard>>(url, event)
|
|
12
|
+
.then(categories => (Array.isArray(categories) ? categories.map(category => category.name) : null))
|
|
13
|
+
.catch(() => null);
|
|
14
|
+
}
|
|
@@ -36,7 +36,9 @@ export interface IRawVideoData {
|
|
|
36
36
|
sizeCache?: string,
|
|
37
37
|
state?: string,
|
|
38
38
|
title: string,
|
|
39
|
-
unknownFields?: string
|
|
39
|
+
unknownFields?: string,
|
|
40
|
+
/** Не из API: ставит BFF по топ-списку категорий. */
|
|
41
|
+
isTop?: boolean
|
|
40
42
|
}[];
|
|
41
43
|
actions: null;
|
|
42
44
|
primary_thumb_rank: number; // unused
|
package/types/video-data.d.ts
CHANGED