itube-specs 0.0.1073 → 0.0.1075
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 +30 -0
- package/assets/scss/themes/dark/_theme.scss +18 -7
- package/assets/scss/themes/light/_theme.scss +18 -7
- package/components/layout/age-popup.vue +81 -0
- package/components/player/core.vue +69 -2
- package/components/player/scenes.vue +138 -0
- package/components/ui/img.vue +30 -6
- package/components/view/video.vue +9 -4
- package/i18n/locales/de-DE.json +1 -0
- package/i18n/locales/en-US.json +3 -2
- package/i18n/locales/es-ES.json +1 -0
- package/i18n/locales/fr-FR.json +1 -0
- package/i18n/locales/it-IT.json +1 -0
- package/i18n/locales/ja-JP.json +1 -0
- package/i18n/locales/ko-KR.json +1 -0
- package/i18n/locales/ru-RU.json +1 -0
- package/i18n/locales/zh-CN.json +1 -0
- package/layouts/default.vue +5 -1
- package/package.json +1 -1
- package/public/img/age-gate.webp +0 -0
- package/runtime/index.ts +0 -1
- package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +17 -0
- package/runtime/utils/cleaners/clean-video-data.ts +8 -1
- package/runtime/utils/converters/convert-tag-group-item-to-chips.ts +1 -1
- package/server/api/videos/get-video-by-id/[id].get.ts +3 -4
- package/server/api/videos/get-video-by-url/[url].get.ts +3 -4
- package/types/raw/raw-video-data.d.ts +0 -2
- package/types/video-data.d.ts +4 -3
- package/components/layout/age-banner.vue +0 -108
- package/runtime/utils/__tests__/video-data-mark-top-categories.test.ts +0 -19
- package/runtime/utils/video-data-mark-top-categories.ts +0 -21
- package/server/utils/top-category-names.ts +0 -14
package/SITE_UPDATES.md
CHANGED
|
@@ -11,6 +11,36 @@
|
|
|
11
11
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
12
12
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
13
13
|
|
|
14
|
+
## 0.0.1075 — 2026-11-06
|
|
15
|
+
|
|
16
|
+
- **Сайтам со своей темой в `assets/scss/vars/_theme.scss` (hdsex-remaster), срочно:** без нового токена сборка
|
|
17
|
+
падает на `Undefined variable`. Добавить `$layout-age-popup-text` (тёмная — `$color-zinc-400`, светлая —
|
|
18
|
+
`$color-gray-600`), `$ui-img-gate-badge-text: $color-white;`, `$ui-img-gate-badge-bg: $color-black-42;`,
|
|
19
|
+
`$ui-img-gate-badge-border: $mono-white-90;`. Шесть `$layout-age-banner-*` и `$ui-img-gate-text` больше не
|
|
20
|
+
нужны — удалить.
|
|
21
|
+
- **Сайтам с `FeatureAgeVerificationEnabled` — к сведению:** заглушка превью — размытая абстрактная картинка слоя
|
|
22
|
+
(`public/img/age-gate.webp`, без реального кадра) со значком «18+» в кружке; в аватарах меньше 40px — без значка.
|
|
23
|
+
- **Сайтам с `FeatureAgeVerificationEnabled` — к сведению:** плашка под шапкой (`LayoutAgeBanner`) заменена попапом
|
|
24
|
+
`LayoutAgePopup` — предупреждение о верификации в регионе, кнопки «Verify age» и «Close»; после закрытия
|
|
25
|
+
показывается снова на следующей странице. Заглушки превью и блокировка плеера — как были. Тексты `age_gate.title`
|
|
26
|
+
и `age_gate.text` переписаны под попап.
|
|
27
|
+
|
|
28
|
+
## 0.0.1074 — 2026-11-05
|
|
29
|
+
|
|
30
|
+
- **Сайтам со своей темой в `assets/scss/vars/_theme.scss` (hdsex-remaster), срочно:** без новых токенов сборка
|
|
31
|
+
падает на `Undefined variable`. Добавить `$player-scenes-text`, `$player-scenes-text--current`, `$player-scenes-bg`,
|
|
32
|
+
`$player-scenes-bg--hover`, `$player-scenes-bg--current`, `$player-scenes-border`, `$player-scenes-border--current`,
|
|
33
|
+
`$player-scenes-time`, `$player-scenes-time-bg`, `$player-scenes-time-bg--current`,
|
|
34
|
+
`$player-scenes-time-border--current`, `$player-scenes-mask` — значения из `themes/dark/_theme.scss` слоя.
|
|
35
|
+
- **Всем — к сведению:** под плеером ряд сцен ролика (`PlayerScenes`, макет v0-dark-eta /video/2): «тайм-код |
|
|
36
|
+
название», текущая подсвечена, клик перематывает (до загрузки плеера — стартует его и перематывает). Сцены — первый
|
|
37
|
+
тип из `timelines_groups` (дальше — выбранный в плеере), без групп — `actions`. Нет сцен у ролика — ряда нет. Новый
|
|
38
|
+
общий ключ `scenes`. Делать ничего не надо.
|
|
39
|
+
- **Всем — к сведению:** категории в строке чипсов над плеером теперь берутся из группы `top_categories` ответа
|
|
40
|
+
видео v2 (`IVideoData.topCategories`) как есть, без сверки с `categories`; в блок под роликом эта группа не
|
|
41
|
+
попадает. Пометка `categories[].isTop`, `videoDataMarkTopCategories` и запрос BFF к `/v1/categories/top` убраны.
|
|
42
|
+
Ролик без `top_categories` — категорий над плеером нет. Делать ничего не надо, если сайт не читал `isTop` сам.
|
|
43
|
+
|
|
14
44
|
## 0.0.1073 — 2026-11-03
|
|
15
45
|
|
|
16
46
|
- **Всем — к сведению:** гость больше не видит заглушку «Sign in to upload» на `/upload`: клик по Upload (шапка,
|
|
@@ -33,14 +33,11 @@ $layout-header-border: $color-zinc-800-30;
|
|
|
33
33
|
$layout-nav-border: $color-zinc-800-50;
|
|
34
34
|
$layout-skip-link-bg: $color-zinc-800-60;
|
|
35
35
|
$layout-skip-link-text: $color-white;
|
|
36
|
-
$layout-age-
|
|
37
|
-
$layout-age-banner-title: $color-white;
|
|
38
|
-
$layout-age-banner-text: $color-white-90;
|
|
39
|
-
$layout-age-banner-btn-bg: $color-white;
|
|
40
|
-
$layout-age-banner-btn-bg--hover: $color-white-90;
|
|
41
|
-
$layout-age-banner-btn-text: $color-green-900;
|
|
36
|
+
$layout-age-popup-text: $color-zinc-400;
|
|
42
37
|
$ui-img-gate-bg: $color-zinc-800;
|
|
43
|
-
$ui-img-gate-text: $color-
|
|
38
|
+
$ui-img-gate-badge-text: $color-white;
|
|
39
|
+
$ui-img-gate-badge-bg: $color-black-42;
|
|
40
|
+
$ui-img-gate-badge-border: $mono-white-90;
|
|
44
41
|
|
|
45
42
|
// ─── Page footer links (mixin) ───
|
|
46
43
|
$page-footer-links-border: $color-zinc-800;
|
|
@@ -783,6 +780,20 @@ $player-menu-option-bg--hover: $mono-white-5; // hover:bg-white/5
|
|
|
783
780
|
$player-menu-toggle-bg--active: $color-indigo-500; // switch on: bg-indigo-500
|
|
784
781
|
$player-center-label-shadow: $color-black-50; // мета в центре бара поверх видео
|
|
785
782
|
|
|
783
|
+
// ─── Player scenes (сцены под плеером) ───
|
|
784
|
+
$player-scenes-text: $color-zinc-300;
|
|
785
|
+
$player-scenes-text--current: $color-white;
|
|
786
|
+
$player-scenes-bg: transparent;
|
|
787
|
+
$player-scenes-bg--hover: $color-zinc-800;
|
|
788
|
+
$player-scenes-bg--current: $color-zinc-800;
|
|
789
|
+
$player-scenes-border: $color-zinc-800;
|
|
790
|
+
$player-scenes-border--current: $color-zinc-500;
|
|
791
|
+
$player-scenes-time: $color-zinc-400;
|
|
792
|
+
$player-scenes-time-bg: $color-zinc-900;
|
|
793
|
+
$player-scenes-time-bg--current: $color-zinc-700;
|
|
794
|
+
$player-scenes-time-border--current: $color-zinc-600;
|
|
795
|
+
$player-scenes-mask: $color-black; // непрозрачная часть маски затухания ленты
|
|
796
|
+
|
|
786
797
|
// accordion (scene types)
|
|
787
798
|
$player-menu-acc-border: $color-zinc-800-60; // border-zinc-800/60
|
|
788
799
|
$player-menu-acc-head-text: $color-zinc-200; // text-zinc-200
|
|
@@ -44,14 +44,11 @@ $layout-header-border: transparent;
|
|
|
44
44
|
$layout-nav-border: $color-slate-750;
|
|
45
45
|
$layout-skip-link-bg: $color-slate-750;
|
|
46
46
|
$layout-skip-link-text: $color-white;
|
|
47
|
-
$layout-age-
|
|
48
|
-
$layout-age-banner-title: $color-white;
|
|
49
|
-
$layout-age-banner-text: $color-white-90;
|
|
50
|
-
$layout-age-banner-btn-bg: $color-white;
|
|
51
|
-
$layout-age-banner-btn-bg--hover: $color-white-90;
|
|
52
|
-
$layout-age-banner-btn-text: $color-green-900;
|
|
47
|
+
$layout-age-popup-text: $color-gray-600;
|
|
53
48
|
$ui-img-gate-bg: $color-gray-200;
|
|
54
|
-
$ui-img-gate-text: $color-
|
|
49
|
+
$ui-img-gate-badge-text: $color-white;
|
|
50
|
+
$ui-img-gate-badge-bg: $color-black-42;
|
|
51
|
+
$ui-img-gate-badge-border: $mono-white-90;
|
|
55
52
|
|
|
56
53
|
// ─── Page footer links (mixin) ───
|
|
57
54
|
$page-footer-links-border: transparent;
|
|
@@ -835,6 +832,20 @@ $player-menu-option-bg--hover: $mono-white-5; // hover:bg-white/5
|
|
|
835
832
|
$player-menu-toggle-bg--active: $color-indigo-500; // switch on: bg-indigo-500
|
|
836
833
|
$player-center-label-shadow: $color-black-50; // мета в центре бара поверх видео
|
|
837
834
|
|
|
835
|
+
// ─── Player scenes (сцены под плеером) ───
|
|
836
|
+
$player-scenes-text: $color-gray-700;
|
|
837
|
+
$player-scenes-text--current: $color-gray-900;
|
|
838
|
+
$player-scenes-bg: transparent;
|
|
839
|
+
$player-scenes-bg--hover: $color-gray-100;
|
|
840
|
+
$player-scenes-bg--current: $color-gray-200;
|
|
841
|
+
$player-scenes-border: $color-gray-200;
|
|
842
|
+
$player-scenes-border--current: $color-gray-400;
|
|
843
|
+
$player-scenes-time: $color-gray-500;
|
|
844
|
+
$player-scenes-time-bg: $color-gray-100;
|
|
845
|
+
$player-scenes-time-bg--current: $color-gray-300;
|
|
846
|
+
$player-scenes-time-border--current: $color-gray-400;
|
|
847
|
+
$player-scenes-mask: $color-black; // непрозрачная часть маски затухания ленты
|
|
848
|
+
|
|
838
849
|
// accordion (scene types)
|
|
839
850
|
$player-menu-acc-border: $color-slate-700; // border-zinc-800/60
|
|
840
851
|
$player-menu-acc-head-text: $color-gray-200; // text-zinc-200
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<!-- Попап на каждой странице, пока возрастная верификация требуется и не пройдена (useAgeGate) -->
|
|
2
|
+
<template>
|
|
3
|
+
<transition mode="out-in">
|
|
4
|
+
<UiPopup
|
|
5
|
+
v-if="isOpen"
|
|
6
|
+
v-model="isOpen"
|
|
7
|
+
sheet
|
|
8
|
+
>
|
|
9
|
+
<template #title>{{ t('age_gate.title') }}</template>
|
|
10
|
+
|
|
11
|
+
<p class="layout-age-popup__text">{{ t('age_gate.text') }}</p>
|
|
12
|
+
|
|
13
|
+
<template #footer>
|
|
14
|
+
<div class="layout-age-popup__buttons">
|
|
15
|
+
<UiBtn
|
|
16
|
+
wide
|
|
17
|
+
size="l"
|
|
18
|
+
theme="secondary"
|
|
19
|
+
@click="isOpen = false"
|
|
20
|
+
>{{ t('close') }}</UiBtn>
|
|
21
|
+
<UiBtn
|
|
22
|
+
:disabled="isPending"
|
|
23
|
+
wide
|
|
24
|
+
size="l"
|
|
25
|
+
theme="primary"
|
|
26
|
+
@click="onVerify"
|
|
27
|
+
>{{ t('age_gate.button') }}</UiBtn>
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
|
30
|
+
</UiPopup>
|
|
31
|
+
</transition>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<script setup lang="ts">
|
|
35
|
+
const { t } = useI18n();
|
|
36
|
+
const route = useRoute();
|
|
37
|
+
const { startVerification } = useAgeGate();
|
|
38
|
+
|
|
39
|
+
const isOpen = ref(true);
|
|
40
|
+
const isPending = ref(false);
|
|
41
|
+
|
|
42
|
+
watch(() => route.path, () => {
|
|
43
|
+
isOpen.value = true;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const onVerify = async () => {
|
|
47
|
+
isPending.value = true;
|
|
48
|
+
try {
|
|
49
|
+
await startVerification(route.fullPath);
|
|
50
|
+
} finally {
|
|
51
|
+
isPending.value = false;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<style lang="scss">
|
|
57
|
+
.layout-age-popup__text {
|
|
58
|
+
@include font-sm;
|
|
59
|
+
|
|
60
|
+
max-width: 400px;
|
|
61
|
+
padding-inline: 8px;
|
|
62
|
+
padding-block: 8px;
|
|
63
|
+
color: $layout-age-popup-text;
|
|
64
|
+
|
|
65
|
+
@include from-br(sm) {
|
|
66
|
+
@include font-base;
|
|
67
|
+
|
|
68
|
+
padding-inline: 16px;
|
|
69
|
+
padding-block: 16px;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.layout-age-popup__buttons {
|
|
74
|
+
display: grid;
|
|
75
|
+
gap: 8px;
|
|
76
|
+
|
|
77
|
+
@include from-br(sm) {
|
|
78
|
+
grid-template-columns: 1fr 1fr;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
@advancerequest="onAdvanceRequest"
|
|
19
19
|
@sourcechange="onSourceChange"
|
|
20
20
|
@menurequest="onMenuRequest"
|
|
21
|
+
@chapterchange="onChapterChange"
|
|
22
|
+
@scenetypechange="onSceneTypeChange"
|
|
21
23
|
@pause="onPause"
|
|
22
|
-
@ready="
|
|
24
|
+
@ready="onReady"
|
|
23
25
|
@blockedclick="startVerification(route.fullPath)"
|
|
24
26
|
>
|
|
25
27
|
<template
|
|
@@ -34,6 +36,13 @@
|
|
|
34
36
|
</template>
|
|
35
37
|
</ITubePlayer>
|
|
36
38
|
|
|
39
|
+
<PlayerScenes
|
|
40
|
+
:scenes="scenes"
|
|
41
|
+
:current="currentSceneStart"
|
|
42
|
+
class="player-core__scenes"
|
|
43
|
+
@select="onSceneSelect"
|
|
44
|
+
/>
|
|
45
|
+
|
|
37
46
|
<!-- Внешнее меню плеера в нашем ui-popup (только мобила — menus: 'external-mobile'). -->
|
|
38
47
|
<PlayerMenu
|
|
39
48
|
v-model:open="menuOpen"
|
|
@@ -44,7 +53,7 @@
|
|
|
44
53
|
<script setup lang="ts">
|
|
45
54
|
import { ITubePlayer } from 'itube-modern-player/vue';
|
|
46
55
|
|
|
47
|
-
import type { Player, PlayerOptions, VideoSource, LocaleCode, AdsOptions, PreviewMetaItem, SceneGroup, MenuRequestPayload } from 'itube-modern-player';
|
|
56
|
+
import type { Player, PlayerOptions, VideoSource, LocaleCode, AdsOptions, PreviewMetaItem, SceneGroup, MenuRequestPayload, Chapter } from 'itube-modern-player';
|
|
48
57
|
import { ThumbSize } from '../../runtime';
|
|
49
58
|
import type { IVideoData, IVideoCard } from '../../types';
|
|
50
59
|
import playIcon from '../../assets/icons/player/play.svg?raw';
|
|
@@ -107,6 +116,57 @@ function onMenuRequest(request: MenuRequestPayload): void {
|
|
|
107
116
|
menuOpen.value = true;
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
// Сцены под плеером (PlayerScenes): до загрузки ядра — первый тип сцен, как в заглушке плеера (или главы), дальше
|
|
120
|
+
// активный тип и текущая сцена приходят событиями. Клик до загрузки стартует плеер через кнопку play заглушки и
|
|
121
|
+
// перематывает на ready.
|
|
122
|
+
const initialScenes = (): Chapter[] =>
|
|
123
|
+
currentSource.value.sceneGroups?.[0]?.scenes
|
|
124
|
+
?? (Array.isArray(currentSource.value.chapters) ? currentSource.value.chapters : []);
|
|
125
|
+
|
|
126
|
+
const scenes = shallowRef<Chapter[]>([]);
|
|
127
|
+
const currentSceneStart = ref<number | null>(null);
|
|
128
|
+
let pendingSeek: number | null = null;
|
|
129
|
+
|
|
130
|
+
function onChapterChange({ chapter }: { chapter: Chapter | null }): void {
|
|
131
|
+
currentSceneStart.value = chapter?.start ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function onSceneTypeChange({ group }: { group: SceneGroup | null }): void {
|
|
135
|
+
scenes.value = group?.scenes ?? initialScenes();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// На ready у HLS ещё нет метаданных и перемотка теряется — ждём первый timeupdate с известной длительностью.
|
|
139
|
+
function onReady(): void {
|
|
140
|
+
siteHooks.onReady?.();
|
|
141
|
+
const player = playerRef.value?.player;
|
|
142
|
+
if (pendingSeek === null || !player) return;
|
|
143
|
+
|
|
144
|
+
const stop = player.on('timeupdate', ({ duration }) => {
|
|
145
|
+
if (!(duration > 0) || pendingSeek === null) return;
|
|
146
|
+
|
|
147
|
+
player.seek(pendingSeek);
|
|
148
|
+
pendingSeek = null;
|
|
149
|
+
stop();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function onSceneSelect(start: number): void {
|
|
154
|
+
if (isAgeGated.value) {
|
|
155
|
+
startVerification(route.fullPath);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const player = playerRef.value?.player;
|
|
160
|
+
if (player) {
|
|
161
|
+
player.seek(start);
|
|
162
|
+
player.play();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
pendingSeek = start;
|
|
167
|
+
(playerRef.value?.$el as HTMLElement | undefined)?.querySelector<HTMLElement>('.imp-ph-play, .imp-ph-btn--play')?.click();
|
|
168
|
+
}
|
|
169
|
+
|
|
110
170
|
// Плеер эмитит advancerequest 'ended' только при включённом тумблере Autoplay, а на
|
|
111
171
|
// кнопку next — 'next'. Значит play после свапа нужен ТОЛЬКО когда переход инициировали
|
|
112
172
|
// мы (next / автопереход), а не когда source пересобрался по другой причине (сменилось
|
|
@@ -278,8 +338,11 @@ const buildSource = (): VideoSource | VideoSource[] =>
|
|
|
278
338
|
// следующего (префетч) дёргала бы player.load() и перезапускала текущий ролик + ломала
|
|
279
339
|
// автоплей (лишние sourcechange). Один переход = один reload.
|
|
280
340
|
const source = shallowRef<VideoSource | VideoSource[]>(buildSource());
|
|
341
|
+
scenes.value = initialScenes();
|
|
281
342
|
watch(() => props.data.videoUrl, () => {
|
|
282
343
|
source.value = buildSource();
|
|
344
|
+
scenes.value = initialScenes();
|
|
345
|
+
currentSceneStart.value = null;
|
|
283
346
|
});
|
|
284
347
|
|
|
285
348
|
/**
|
|
@@ -370,6 +433,10 @@ async function loadAdConfig(): Promise<AdsOptions | undefined> {
|
|
|
370
433
|
}
|
|
371
434
|
}
|
|
372
435
|
|
|
436
|
+
.player-core__scenes {
|
|
437
|
+
margin-top: 12px;
|
|
438
|
+
}
|
|
439
|
+
|
|
373
440
|
// Мета ролика в центре бара: по макету белая с тенью и средней жирностью,
|
|
374
441
|
// у плеера по умолчанию — приглушённая и без тени.
|
|
375
442
|
.player-core .imp-controls__chapter {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<!-- Сцены ролика под плеером (внешний контрол, как PlayerMenu): «тайм-код | название», текущая подсвечена.
|
|
2
|
+
Данные и перемотку держит PlayerCore — сцены активного типа и текущую сцену он берёт из событий плеера.
|
|
3
|
+
До 960px — одна строка с прокруткой и затуханием у правого края, шире — перенос строк. -->
|
|
4
|
+
<template>
|
|
5
|
+
<nav
|
|
6
|
+
v-if="scenes.length"
|
|
7
|
+
:aria-label="t('scenes')"
|
|
8
|
+
class="player-scenes"
|
|
9
|
+
>
|
|
10
|
+
<ol class="player-scenes__list">
|
|
11
|
+
<li
|
|
12
|
+
v-for="scene in scenes"
|
|
13
|
+
:key="scene.start"
|
|
14
|
+
class="player-scenes__item"
|
|
15
|
+
>
|
|
16
|
+
<button
|
|
17
|
+
:class="{ '--current': scene.start === current }"
|
|
18
|
+
:aria-current="scene.start === current || undefined"
|
|
19
|
+
:aria-label="`${scene.title}, ${formatSceneTime(scene.start)}`"
|
|
20
|
+
class="player-scenes__btn"
|
|
21
|
+
type="button"
|
|
22
|
+
@click="emit('select', scene.start)"
|
|
23
|
+
>
|
|
24
|
+
<span class="player-scenes__time">{{ formatSceneTime(scene.start) }}</span>
|
|
25
|
+
<span class="player-scenes__title">{{ scene.title }}</span>
|
|
26
|
+
</button>
|
|
27
|
+
</li>
|
|
28
|
+
</ol>
|
|
29
|
+
</nav>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<script setup lang="ts">
|
|
33
|
+
import type { Chapter } from 'itube-modern-player';
|
|
34
|
+
|
|
35
|
+
defineProps<{
|
|
36
|
+
scenes: Chapter[]
|
|
37
|
+
/** `start` текущей сцены, `null` — ни одна не идёт. */
|
|
38
|
+
current: number | null
|
|
39
|
+
}>();
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits<{
|
|
42
|
+
(eventName: 'select', start: number): void
|
|
43
|
+
}>();
|
|
44
|
+
|
|
45
|
+
const { t } = useI18n();
|
|
46
|
+
|
|
47
|
+
/** Как в контрол-баре плеера: минуты без ведущего нуля — `0:55`, `1:02:03`. */
|
|
48
|
+
function formatSceneTime(seconds: number): string {
|
|
49
|
+
const total = Math.max(0, Math.floor(seconds));
|
|
50
|
+
const h = Math.floor(total / 3600);
|
|
51
|
+
const m = Math.floor((total % 3600) / 60);
|
|
52
|
+
const s = String(total % 60).padStart(2, '0');
|
|
53
|
+
|
|
54
|
+
return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<style lang="scss">
|
|
59
|
+
.player-scenes__list {
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-wrap: nowrap;
|
|
62
|
+
gap: 8px;
|
|
63
|
+
overflow-x: auto;
|
|
64
|
+
margin-inline: calc(-1 * var(--container-padding-inline));
|
|
65
|
+
padding-inline: var(--container-padding-inline) 40px;
|
|
66
|
+
scrollbar-width: none;
|
|
67
|
+
mask-image: linear-gradient(to right, $player-scenes-mask calc(100% - 40px), transparent);
|
|
68
|
+
|
|
69
|
+
&::-webkit-scrollbar {
|
|
70
|
+
display: none;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@include from-br(sm) {
|
|
74
|
+
flex-wrap: wrap;
|
|
75
|
+
overflow-x: visible;
|
|
76
|
+
margin-inline: 0;
|
|
77
|
+
padding-inline: 0;
|
|
78
|
+
mask-image: none;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.player-scenes__item {
|
|
83
|
+
flex-shrink: 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.player-scenes__btn {
|
|
87
|
+
@include font-xs;
|
|
88
|
+
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
height: 36px;
|
|
92
|
+
overflow: hidden;
|
|
93
|
+
white-space: nowrap;
|
|
94
|
+
color: $player-scenes-text;
|
|
95
|
+
background-color: $player-scenes-bg;
|
|
96
|
+
border: 1px solid $player-scenes-border;
|
|
97
|
+
border-radius: $radius-md;
|
|
98
|
+
|
|
99
|
+
@include hover(background-color, color) {
|
|
100
|
+
color: $player-scenes-text--current;
|
|
101
|
+
background-color: $player-scenes-bg--hover;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@include from-br(sm) {
|
|
105
|
+
@include font-sm;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
&.--current {
|
|
109
|
+
color: $player-scenes-text--current;
|
|
110
|
+
background-color: $player-scenes-bg--current;
|
|
111
|
+
border-color: $player-scenes-border--current;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.player-scenes__time {
|
|
116
|
+
@include font-xs;
|
|
117
|
+
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
height: 100%;
|
|
121
|
+
padding-inline: 10px;
|
|
122
|
+
font-weight: 500;
|
|
123
|
+
font-variant-numeric: tabular-nums;
|
|
124
|
+
color: $player-scenes-time;
|
|
125
|
+
background-color: $player-scenes-time-bg;
|
|
126
|
+
border-right: 1px solid $player-scenes-border;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.player-scenes__btn.--current .player-scenes__time {
|
|
130
|
+
color: $player-scenes-text--current;
|
|
131
|
+
background-color: $player-scenes-time-bg--current;
|
|
132
|
+
border-right-color: $player-scenes-time-border--current;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.player-scenes__title {
|
|
136
|
+
padding-inline: 12px;
|
|
137
|
+
}
|
|
138
|
+
</style>
|
package/components/ui/img.vue
CHANGED
|
@@ -125,29 +125,53 @@ const emit = defineEmits<{
|
|
|
125
125
|
.ui-img-gate {
|
|
126
126
|
container-type: inline-size;
|
|
127
127
|
background-color: $ui-img-gate-bg;
|
|
128
|
+
background-image: url('/img/age-gate.webp');
|
|
129
|
+
background-position: center;
|
|
130
|
+
background-size: cover;
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
.ui-img-gate__label {
|
|
131
|
-
@include font-
|
|
134
|
+
@include font-base;
|
|
132
135
|
|
|
133
136
|
position: absolute;
|
|
134
|
-
|
|
137
|
+
top: 50%;
|
|
138
|
+
left: 50%;
|
|
135
139
|
display: flex;
|
|
136
140
|
align-items: center;
|
|
137
141
|
justify-content: center;
|
|
138
|
-
|
|
139
|
-
|
|
142
|
+
width: 56px;
|
|
143
|
+
height: 56px;
|
|
144
|
+
font-weight: 800;
|
|
145
|
+
letter-spacing: var(--tracking-tight);
|
|
146
|
+
color: $ui-img-gate-badge-text;
|
|
147
|
+
background-color: $ui-img-gate-badge-bg;
|
|
148
|
+
border: 2px solid $ui-img-gate-badge-border;
|
|
149
|
+
border-radius: $radius-full;
|
|
150
|
+
transform: translate(-50%, -50%);
|
|
140
151
|
}
|
|
141
152
|
|
|
142
|
-
@include to-cq(
|
|
153
|
+
@include to-cq(240px) {
|
|
143
154
|
.ui-img-gate__label {
|
|
144
155
|
@include font-xs;
|
|
156
|
+
|
|
157
|
+
width: 40px;
|
|
158
|
+
height: 40px;
|
|
145
159
|
}
|
|
146
160
|
}
|
|
147
161
|
|
|
148
|
-
@include to-cq(
|
|
162
|
+
@include to-cq(96px) {
|
|
149
163
|
.ui-img-gate__label {
|
|
150
164
|
@include font-4xs;
|
|
165
|
+
|
|
166
|
+
width: 26px;
|
|
167
|
+
height: 26px;
|
|
168
|
+
border-width: 1px;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@include to-cq(40px) {
|
|
173
|
+
.ui-img-gate__label {
|
|
174
|
+
display: none;
|
|
151
175
|
}
|
|
152
176
|
}
|
|
153
177
|
</style>
|
|
@@ -239,10 +239,15 @@ const categoriesChips = computed((): IChipsItem[] => props.data.categories && pr
|
|
|
239
239
|
prefix: 'categories',
|
|
240
240
|
})));
|
|
241
241
|
|
|
242
|
-
// Над плеером —
|
|
243
|
-
// Теги, совпавшие с
|
|
244
|
-
const topCategoriesChips = computed((): IChipsItem[] => (
|
|
245
|
-
.
|
|
242
|
+
// Над плеером — категории из группы `top_categories`, как прислал бекенд.
|
|
243
|
+
// Теги, совпавшие с любой категорией ролика, всё равно отсекаются по полному списку ниже.
|
|
244
|
+
const topCategoriesChips = computed((): IChipsItem[] => (props.data.topCategories ?? []).map(item => ({
|
|
245
|
+
title: item.title,
|
|
246
|
+
value: item.name,
|
|
247
|
+
image: item.icon ? `/icons/flags/${item.icon}.svg` : undefined,
|
|
248
|
+
imageFit: 'contain',
|
|
249
|
+
prefix: 'categories',
|
|
250
|
+
})));
|
|
246
251
|
|
|
247
252
|
const categoriesTitles = computed<string[]>(() => props.data.categories && props.data.categories?.map(item => item.title?.replace(/\s+/g, '').toLowerCase() ?? ''));
|
|
248
253
|
|
package/i18n/locales/de-DE.json
CHANGED
package/i18n/locales/en-US.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
+
"scenes": "Scenes",
|
|
2
3
|
"register_to_upload": "Please sign up or log in to upload your videos",
|
|
3
4
|
"age_gate": {
|
|
4
5
|
"thumb": "18+",
|
|
5
|
-
"title": "
|
|
6
|
-
"text": "
|
|
6
|
+
"title": "Age verification required",
|
|
7
|
+
"text": "Laws in your country or region require you to confirm you are 18+ to view adult content. It takes a minute, and we keep no personal data.",
|
|
7
8
|
"button": "Verify age",
|
|
8
9
|
"player_text": "Confirm your age to watch this video"
|
|
9
10
|
},
|
package/i18n/locales/es-ES.json
CHANGED
package/i18n/locales/fr-FR.json
CHANGED
package/i18n/locales/it-IT.json
CHANGED
package/i18n/locales/ja-JP.json
CHANGED
package/i18n/locales/ko-KR.json
CHANGED
package/i18n/locales/ru-RU.json
CHANGED
package/i18n/locales/zh-CN.json
CHANGED
package/layouts/default.vue
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
|
|
13
13
|
<LayoutSecondNav class="_to-sd"/>
|
|
14
14
|
|
|
15
|
-
<
|
|
15
|
+
<ClientOnly>
|
|
16
|
+
<LazyLayoutAgePopup v-if="isAgeGated"/>
|
|
17
|
+
</ClientOnly>
|
|
16
18
|
|
|
17
19
|
<main
|
|
18
20
|
id="main"
|
|
@@ -60,5 +62,7 @@ const { snackbarText, isSnackBarInPopup } = useSnackbar();
|
|
|
60
62
|
|
|
61
63
|
const { isAuthorized } = useUser(AuthorizationApiService);
|
|
62
64
|
|
|
65
|
+
const { isAgeGated } = useAgeGate();
|
|
66
|
+
|
|
63
67
|
const htmlAttrs = computed(() => head.value.htmlAttrs);
|
|
64
68
|
</script>
|
package/package.json
CHANGED
|
Binary file
|
package/runtime/index.ts
CHANGED
|
@@ -47,7 +47,6 @@ export * from './utils/cleaners/clean-video-card';
|
|
|
47
47
|
export * from './utils/cleaners/clean-model-info';
|
|
48
48
|
export * from './utils/cleaners/clean-suggest-models';
|
|
49
49
|
export * from './utils/video-data-add-model-portraits';
|
|
50
|
-
export * from './utils/video-data-mark-top-categories';
|
|
51
50
|
export * from './utils/converters/convert-categories-to-footer';
|
|
52
51
|
export * from './utils/converters/convert-categories-to-chips';
|
|
53
52
|
export * from './utils/converters/convert-tag-group-item-to-chips';
|
|
@@ -197,4 +197,21 @@ describe('cleanVideoData — группы под плеером', () => {
|
|
|
197
197
|
expect(data.tagGroups[0].items.map(item => item.name)).toEqual(['anal', 'rimming', 'doggy']);
|
|
198
198
|
expect(data.tags).toEqual(['anal']);
|
|
199
199
|
});
|
|
200
|
+
|
|
201
|
+
it('v2: top_categories — в topCategories по order, в группы под плеером не попадает', () => {
|
|
202
|
+
const data = cleanVideoData(raw([
|
|
203
|
+
{ type: 'category', name: 'actions', title: 'Actions', order: 1, items: [{ name: 'anal', title: 'Anal' }] },
|
|
204
|
+
{ type: 'top_categories', name: 'top_categories', title: 'top_categories', order: 2, items: [
|
|
205
|
+
{ name: 'hunk', title: 'Hunk', order: 2, icon: '' },
|
|
206
|
+
{ name: 'business man', title: 'Businessman', order: 1, icon: '' },
|
|
207
|
+
] },
|
|
208
|
+
]));
|
|
209
|
+
|
|
210
|
+
expect(data.topCategories.map(item => item.name)).toEqual(['business man', 'hunk']);
|
|
211
|
+
expect(data.tagGroups.map(group => group.name)).toEqual(['actions']);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('без top_categories — строка над плеером без категорий', () => {
|
|
215
|
+
expect(cleanVideoData(raw(undefined)).topCategories).toEqual([]);
|
|
216
|
+
});
|
|
200
217
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IRawVideoData, IVideoData, ISceneGroup, ITimelineGroup, IVideoTagGroup } from '../../../types';
|
|
1
|
+
import type { IRawVideoData, IVideoData, ISceneGroup, ITimelineGroup, IVideoTagGroup, IVideoTagGroupItem } from '../../../types';
|
|
2
2
|
import { pickPortraitVariant } from '../pick-portrait-variant';
|
|
3
3
|
import { modelGender } from '../model-placeholder';
|
|
4
4
|
|
|
@@ -82,6 +82,12 @@ const cleanTagGroups = (raw: IRawVideoData['entities']): IVideoTagGroup[] => (Ar
|
|
|
82
82
|
.filter(group => group.items.length > 0)
|
|
83
83
|
.sort(byOrder);
|
|
84
84
|
|
|
85
|
+
// Строка над плеером: элементы группы `top_categories` как есть, без сверки с `categories`.
|
|
86
|
+
const cleanTopCategories = (raw: IRawVideoData['entities']): IVideoTagGroupItem[] => ((Array.isArray(raw) ? raw : [])
|
|
87
|
+
.find(group => group?.type === 'top_categories')?.items ?? [])
|
|
88
|
+
.filter(item => item?.title && item.name)
|
|
89
|
+
.sort(byOrder);
|
|
90
|
+
|
|
85
91
|
/** Нормализует сырые данные видео к типу IVideoData. */
|
|
86
92
|
export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData => ({
|
|
87
93
|
guid: data.guid || '',
|
|
@@ -96,6 +102,7 @@ export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData
|
|
|
96
102
|
tagGroups: cleanTagGroups(data.entities),
|
|
97
103
|
models: cleanVideoModels(data.models, theme),
|
|
98
104
|
categories: data.categories || [],
|
|
105
|
+
topCategories: cleanTopCategories(data.entities),
|
|
99
106
|
actions: data.actions || null,
|
|
100
107
|
channelName: data.channel?.name || '',
|
|
101
108
|
channelAvatar: data.channel?.avatar_url || '',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IChipsItem, IVideoTagGroupItem, VideoTagGroupType } from '../../../types';
|
|
2
2
|
|
|
3
|
-
const PREFIX: Record<VideoTagGroupType, string> = { tag: 'tag', category: 'categories', search: 'search' };
|
|
3
|
+
const PREFIX: Record<VideoTagGroupType, string> = { tag: 'tag', category: 'categories', search: 'search', top_categories: 'categories' };
|
|
4
4
|
|
|
5
5
|
/** Элемент группы под плеером (контракт видео v2) → чипса: ссылка по типу группы и машинному `name`, показ по `title`. */
|
|
6
6
|
export function convertTagGroupItemToChips(type: VideoTagGroupType, item: IVideoTagGroupItem): IChipsItem {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
|
|
2
|
-
import { cleanVideoData, videoDataAddModelPortraits,
|
|
2
|
+
import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
5
5
|
const theme = useRuntimeConfig(event).public.theme;
|
|
@@ -17,16 +17,15 @@ 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
|
|
20
|
+
const [videoRaw, modelsRaw] = 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),
|
|
27
26
|
]);
|
|
28
27
|
|
|
29
|
-
const videoData =
|
|
28
|
+
const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
|
|
30
29
|
const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
|
|
31
30
|
|
|
32
31
|
return cleanVideoData(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cleanVideoData, videoDataAddModelPortraits,
|
|
1
|
+
import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
|
|
2
2
|
import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
@@ -15,16 +15,15 @@ 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
|
|
18
|
+
const [videoRaw, modelsRaw] = 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),
|
|
25
24
|
]);
|
|
26
25
|
|
|
27
|
-
const videoData =
|
|
26
|
+
const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
|
|
28
27
|
const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
|
|
29
28
|
|
|
30
29
|
return cleanVideoData(
|
package/types/video-data.d.ts
CHANGED
|
@@ -30,7 +30,8 @@ export interface ISceneGroupScene {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/** Тип узла группы (контракт ручки видео v2): по нему строится ссылка элементов — тег, категория или поиск. */
|
|
33
|
-
|
|
33
|
+
/** `top_categories` — не группа под плеером, а список категорий для строки над плеером (`IVideoData.topCategories`). */
|
|
34
|
+
export type VideoTagGroupType = 'tag' | 'category' | 'search' | 'top_categories';
|
|
34
35
|
|
|
35
36
|
/** Элемент группы (контракт ручки видео v2). `name` — машинное название (ссылка), `title` — для показа. */
|
|
36
37
|
export interface IVideoTagGroupItem {
|
|
@@ -82,9 +83,9 @@ export interface IVideoData {
|
|
|
82
83
|
name: string;
|
|
83
84
|
title: string;
|
|
84
85
|
icon?: string;
|
|
85
|
-
/** Топ-категория — попадает в строку над плеером (BFF, по кешу `/v1/categories/top`). */
|
|
86
|
-
isTop?: boolean;
|
|
87
86
|
}[];
|
|
87
|
+
/** Категории строки над плеером — из группы `top_categories` ответа v2, как прислал бекенд. */
|
|
88
|
+
topCategories: IVideoTagGroupItem[];
|
|
88
89
|
actions: null;
|
|
89
90
|
channelName: string;
|
|
90
91
|
channelAvatar: string;
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
<!-- Плашка под шапкой, пока возрастная верификация требуется и не пройдена (useAgeGate) -->
|
|
2
|
-
<template>
|
|
3
|
-
<section
|
|
4
|
-
v-if="isAgeGated"
|
|
5
|
-
class="layout-age-banner"
|
|
6
|
-
>
|
|
7
|
-
<div class="layout-age-banner__inner _container">
|
|
8
|
-
<p class="layout-age-banner__content">
|
|
9
|
-
<strong class="layout-age-banner__title">{{ t('age_gate.title') }}</strong>
|
|
10
|
-
<span class="layout-age-banner__text">{{ t('age_gate.text') }}</span>
|
|
11
|
-
</p>
|
|
12
|
-
<UiBtn
|
|
13
|
-
:disabled="isPending"
|
|
14
|
-
:title="t('age_gate.button')"
|
|
15
|
-
theme="primary"
|
|
16
|
-
size="l"
|
|
17
|
-
class="layout-age-banner__btn"
|
|
18
|
-
@click="onVerify"
|
|
19
|
-
>{{ t('age_gate.button') }}</UiBtn>
|
|
20
|
-
</div>
|
|
21
|
-
</section>
|
|
22
|
-
</template>
|
|
23
|
-
|
|
24
|
-
<script setup lang="ts">
|
|
25
|
-
const { t } = useI18n();
|
|
26
|
-
const route = useRoute();
|
|
27
|
-
const { isAgeGated, startVerification } = useAgeGate();
|
|
28
|
-
|
|
29
|
-
const isPending = ref(false);
|
|
30
|
-
|
|
31
|
-
const onVerify = async () => {
|
|
32
|
-
isPending.value = true;
|
|
33
|
-
try {
|
|
34
|
-
await startVerification(route.fullPath);
|
|
35
|
-
} finally {
|
|
36
|
-
isPending.value = false;
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
</script>
|
|
40
|
-
|
|
41
|
-
<style lang="scss">
|
|
42
|
-
.layout-age-banner {
|
|
43
|
-
position: sticky;
|
|
44
|
-
top: var(--header-height);
|
|
45
|
-
z-index: calc(#{$z-index-header} - 2);
|
|
46
|
-
padding-block: 10px;
|
|
47
|
-
background-color: $layout-age-banner-bg;
|
|
48
|
-
|
|
49
|
-
@include from-br($brkpnt-burger) {
|
|
50
|
-
$layout-nav-height: 59px;
|
|
51
|
-
|
|
52
|
-
top: calc(var(--header-height) + #{$layout-nav-height});
|
|
53
|
-
padding-block: 12px;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
.layout-age-banner__inner {
|
|
58
|
-
display: flex;
|
|
59
|
-
flex-direction: column;
|
|
60
|
-
align-items: center;
|
|
61
|
-
gap: 12px;
|
|
62
|
-
text-align: center;
|
|
63
|
-
|
|
64
|
-
@include from-br(sm) {
|
|
65
|
-
flex-direction: row;
|
|
66
|
-
justify-content: space-between;
|
|
67
|
-
gap: 32px;
|
|
68
|
-
text-align: left;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
.layout-age-banner__content {
|
|
73
|
-
display: flex;
|
|
74
|
-
flex-direction: column;
|
|
75
|
-
gap: 4px;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
.layout-age-banner__title {
|
|
79
|
-
@include font-base;
|
|
80
|
-
|
|
81
|
-
font-weight: 700;
|
|
82
|
-
color: $layout-age-banner-title;
|
|
83
|
-
|
|
84
|
-
@include from-br(sm) {
|
|
85
|
-
@include font-lg;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
.layout-age-banner__text {
|
|
90
|
-
@include font-sm;
|
|
91
|
-
|
|
92
|
-
color: $layout-age-banner-text;
|
|
93
|
-
|
|
94
|
-
@include from-br(sm) {
|
|
95
|
-
@include font-base;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
.layout-age-banner__btn {
|
|
100
|
-
--button-bg: #{$layout-age-banner-btn-bg};
|
|
101
|
-
--button-bg--hover: #{$layout-age-banner-btn-bg--hover};
|
|
102
|
-
--button-text: #{$layout-age-banner-btn-text};
|
|
103
|
-
--button-border: transparent;
|
|
104
|
-
|
|
105
|
-
flex-shrink: 0;
|
|
106
|
-
font-weight: 700;
|
|
107
|
-
}
|
|
108
|
-
</style>
|
|
@@ -1,19 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
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,14 +0,0 @@
|
|
|
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
|
-
}
|