itube-specs 0.0.1072 → 0.0.1074
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 +22 -0
- package/assets/scss/themes/dark/_theme.scss +14 -0
- package/assets/scss/themes/light/_theme.scss +14 -0
- package/components/layout/burger.vue +4 -5
- package/components/layout/header.vue +8 -0
- package/components/player/core.vue +69 -2
- package/components/player/scenes.vue +138 -0
- package/components/upload/main.vue +1 -7
- package/components/upload/picker.vue +6 -67
- package/components/view/video.vue +9 -4
- package/composables/use-upload-entry.ts +33 -0
- package/i18n/locales/de-DE.json +1 -0
- package/i18n/locales/en-US.json +2 -1
- package/i18n/locales/es-ES.json +1 -0
- package/i18n/locales/fr-FR.json +1 -0
- package/i18n/locales/it-IT.json +1 -0
- package/i18n/locales/ja-JP.json +1 -0
- package/i18n/locales/ko-KR.json +1 -0
- package/i18n/locales/ru-RU.json +1 -0
- package/i18n/locales/zh-CN.json +1 -0
- package/package.json +1 -1
- package/runtime/constants/upload.ts +0 -1
- 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/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,28 @@
|
|
|
11
11
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
12
12
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
13
13
|
|
|
14
|
+
## 0.0.1074 — 2026-11-05
|
|
15
|
+
|
|
16
|
+
- **Сайтам со своей темой в `assets/scss/vars/_theme.scss` (hdsex-remaster), срочно:** без новых токенов сборка
|
|
17
|
+
падает на `Undefined variable`. Добавить `$player-scenes-text`, `$player-scenes-text--current`, `$player-scenes-bg`,
|
|
18
|
+
`$player-scenes-bg--hover`, `$player-scenes-bg--current`, `$player-scenes-border`, `$player-scenes-border--current`,
|
|
19
|
+
`$player-scenes-time`, `$player-scenes-time-bg`, `$player-scenes-time-bg--current`,
|
|
20
|
+
`$player-scenes-time-border--current`, `$player-scenes-mask` — значения из `themes/dark/_theme.scss` слоя.
|
|
21
|
+
- **Всем — к сведению:** под плеером ряд сцен ролика (`PlayerScenes`, макет v0-dark-eta /video/2): «тайм-код |
|
|
22
|
+
название», текущая подсвечена, клик перематывает (до загрузки плеера — стартует его и перематывает). Сцены — первый
|
|
23
|
+
тип из `timelines_groups` (дальше — выбранный в плеере), без групп — `actions`. Нет сцен у ролика — ряда нет. Новый
|
|
24
|
+
общий ключ `scenes`. Делать ничего не надо.
|
|
25
|
+
- **Всем — к сведению:** категории в строке чипсов над плеером теперь берутся из группы `top_categories` ответа
|
|
26
|
+
видео v2 (`IVideoData.topCategories`) как есть, без сверки с `categories`; в блок под роликом эта группа не
|
|
27
|
+
попадает. Пометка `categories[].isTop`, `videoDataMarkTopCategories` и запрос BFF к `/v1/categories/top` убраны.
|
|
28
|
+
Ролик без `top_categories` — категорий над плеером нет. Делать ничего не надо, если сайт не читал `isTop` сам.
|
|
29
|
+
|
|
30
|
+
## 0.0.1073 — 2026-11-03
|
|
31
|
+
|
|
32
|
+
- **Всем — к сведению:** гость больше не видит заглушку «Sign in to upload» на `/upload`: клик по Upload (шапка,
|
|
33
|
+
бургер) и по выбору файла открывает попап регистрации с пояснением (новый общий ключ `register_to_upload`, пока
|
|
34
|
+
только en-US), после входа — сразу `/upload`. Делать ничего не надо.
|
|
35
|
+
|
|
14
36
|
## 0.0.1071 — 2026-11-03
|
|
15
37
|
|
|
16
38
|
- **Сайтам со своей темой в `assets/scss/vars/_theme.scss` (hd-sex-new), срочно:** без новых токенов сборка падает
|
|
@@ -783,6 +783,20 @@ $player-menu-option-bg--hover: $mono-white-5; // hover:bg-white/5
|
|
|
783
783
|
$player-menu-toggle-bg--active: $color-indigo-500; // switch on: bg-indigo-500
|
|
784
784
|
$player-center-label-shadow: $color-black-50; // мета в центре бара поверх видео
|
|
785
785
|
|
|
786
|
+
// ─── Player scenes (сцены под плеером) ───
|
|
787
|
+
$player-scenes-text: $color-zinc-300;
|
|
788
|
+
$player-scenes-text--current: $color-white;
|
|
789
|
+
$player-scenes-bg: transparent;
|
|
790
|
+
$player-scenes-bg--hover: $color-zinc-800;
|
|
791
|
+
$player-scenes-bg--current: $color-zinc-800;
|
|
792
|
+
$player-scenes-border: $color-zinc-800;
|
|
793
|
+
$player-scenes-border--current: $color-zinc-500;
|
|
794
|
+
$player-scenes-time: $color-zinc-400;
|
|
795
|
+
$player-scenes-time-bg: $color-zinc-900;
|
|
796
|
+
$player-scenes-time-bg--current: $color-zinc-700;
|
|
797
|
+
$player-scenes-time-border--current: $color-zinc-600;
|
|
798
|
+
$player-scenes-mask: $color-black; // непрозрачная часть маски затухания ленты
|
|
799
|
+
|
|
786
800
|
// accordion (scene types)
|
|
787
801
|
$player-menu-acc-border: $color-zinc-800-60; // border-zinc-800/60
|
|
788
802
|
$player-menu-acc-head-text: $color-zinc-200; // text-zinc-200
|
|
@@ -835,6 +835,20 @@ $player-menu-option-bg--hover: $mono-white-5; // hover:bg-white/5
|
|
|
835
835
|
$player-menu-toggle-bg--active: $color-indigo-500; // switch on: bg-indigo-500
|
|
836
836
|
$player-center-label-shadow: $color-black-50; // мета в центре бара поверх видео
|
|
837
837
|
|
|
838
|
+
// ─── Player scenes (сцены под плеером) ───
|
|
839
|
+
$player-scenes-text: $color-gray-700;
|
|
840
|
+
$player-scenes-text--current: $color-gray-900;
|
|
841
|
+
$player-scenes-bg: transparent;
|
|
842
|
+
$player-scenes-bg--hover: $color-gray-100;
|
|
843
|
+
$player-scenes-bg--current: $color-gray-200;
|
|
844
|
+
$player-scenes-border: $color-gray-200;
|
|
845
|
+
$player-scenes-border--current: $color-gray-400;
|
|
846
|
+
$player-scenes-time: $color-gray-500;
|
|
847
|
+
$player-scenes-time-bg: $color-gray-100;
|
|
848
|
+
$player-scenes-time-bg--current: $color-gray-300;
|
|
849
|
+
$player-scenes-time-border--current: $color-gray-400;
|
|
850
|
+
$player-scenes-mask: $color-black; // непрозрачная часть маски затухания ленты
|
|
851
|
+
|
|
838
852
|
// accordion (scene types)
|
|
839
853
|
$player-menu-acc-border: $color-slate-700; // border-zinc-800/60
|
|
840
854
|
$player-menu-acc-head-text: $color-gray-200; // text-zinc-200
|
|
@@ -162,6 +162,7 @@ function logOut() {
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
165
|
+
const { requireAuth } = useUploadEntry();
|
|
165
166
|
|
|
166
167
|
const burgerButtons = computed<{ icon: string, label: string, action?: () => void, link?: string }[]>(() => [
|
|
167
168
|
...isAuthorized.value ? [
|
|
@@ -184,11 +185,9 @@ const burgerButtons = computed<{ icon: string, label: string, action?: () => voi
|
|
|
184
185
|
},
|
|
185
186
|
],
|
|
186
187
|
...featureFlags.FeatureUploadEnabled ? [
|
|
187
|
-
|
|
188
|
-
icon: 'upload',
|
|
189
|
-
label: 'upload',
|
|
190
|
-
link: generateLink('/upload'),
|
|
191
|
-
}
|
|
188
|
+
isAuthorized.value
|
|
189
|
+
? { icon: 'upload', label: 'upload', link: generateLink('/upload') }
|
|
190
|
+
: { icon: 'upload', label: 'upload', action: () => requireAuth() },
|
|
192
191
|
] : [],
|
|
193
192
|
]);
|
|
194
193
|
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
:tabindex="isUploadPage ? -1 : undefined"
|
|
64
64
|
:class="{'--current': isUploadPage}"
|
|
65
65
|
class="layout-header__upload _from-sd"
|
|
66
|
+
@click="onUploadClick"
|
|
66
67
|
>
|
|
67
68
|
<UiIcon
|
|
68
69
|
name="upload-arrow"
|
|
@@ -122,6 +123,13 @@ const route = useRoute();
|
|
|
122
123
|
const { generateLink } = useGenerateLink();
|
|
123
124
|
|
|
124
125
|
const uploadLink = computed(() => generateLink('/upload'));
|
|
126
|
+
const { requireAuth, watchUploadEntry } = useUploadEntry();
|
|
127
|
+
|
|
128
|
+
watchUploadEntry();
|
|
129
|
+
|
|
130
|
+
function onUploadClick(event: MouseEvent) {
|
|
131
|
+
if (!requireAuth()) event.preventDefault();
|
|
132
|
+
}
|
|
125
133
|
const isUploadPage = computed(() => route.path === uploadLink.value);
|
|
126
134
|
|
|
127
135
|
const burgerShow = ref(false);
|
|
@@ -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>
|
|
@@ -67,10 +67,8 @@
|
|
|
67
67
|
|
|
68
68
|
<script setup lang="ts">
|
|
69
69
|
import { UploadAccount, UploadStage } from '../../runtime';
|
|
70
|
-
import { AuthorizationApiService } from '../../services/api/authorization.service';
|
|
71
70
|
|
|
72
71
|
const { t } = useI18n();
|
|
73
|
-
const { isAuthorized } = useUser(AuthorizationApiService);
|
|
74
72
|
|
|
75
73
|
const {
|
|
76
74
|
stage,
|
|
@@ -96,11 +94,7 @@ const isRulesOpen = ref(false);
|
|
|
96
94
|
|
|
97
95
|
const isPick = computed(() => stage.value === UploadStage.Pick);
|
|
98
96
|
|
|
99
|
-
const account = computed(() =>
|
|
100
|
-
if (!isAuthorized.value) return UploadAccount.SignedOut;
|
|
101
|
-
|
|
102
|
-
return remaining.value > 0 ? UploadAccount.Ok : UploadAccount.Limit;
|
|
103
|
-
});
|
|
97
|
+
const account = computed(() => remaining.value > 0 ? UploadAccount.Ok : UploadAccount.Limit);
|
|
104
98
|
|
|
105
99
|
function onBeforeUnload(event: BeforeUnloadEvent) {
|
|
106
100
|
if (!isUploading.value) return;
|
|
@@ -1,34 +1,6 @@
|
|
|
1
|
-
<!-- First upload step:
|
|
1
|
+
<!-- First upload step: drop zone (desktop) or file button (mobile), requirement checks of the picked file; a guest gets the auth popup instead of the file dialog -->
|
|
2
2
|
<template>
|
|
3
|
-
<div
|
|
4
|
-
v-if="account === UploadAccount.SignedOut"
|
|
5
|
-
class="upload-picker__gate"
|
|
6
|
-
>
|
|
7
|
-
<UiIcon
|
|
8
|
-
class="upload-picker__gate-icon"
|
|
9
|
-
name="lock"
|
|
10
|
-
size="24"
|
|
11
|
-
aria-hidden="true"
|
|
12
|
-
/>
|
|
13
|
-
<p class="upload-picker__gate-title">{{ t('upload_page.sign_in') }}</p>
|
|
14
|
-
<div class="upload-picker__gate-buttons">
|
|
15
|
-
<UiBtn
|
|
16
|
-
theme="primary"
|
|
17
|
-
@click="openAuthPopup(AuthSteps.Registration)"
|
|
18
|
-
>{{ t('auth.sign_up') }}
|
|
19
|
-
</UiBtn>
|
|
20
|
-
<UiBtn
|
|
21
|
-
theme="muted"
|
|
22
|
-
@click="openAuthPopup(AuthSteps.Login)"
|
|
23
|
-
>{{ t('auth.log_in') }}
|
|
24
|
-
</UiBtn>
|
|
25
|
-
</div>
|
|
26
|
-
</div>
|
|
27
|
-
|
|
28
|
-
<div
|
|
29
|
-
v-else
|
|
30
|
-
class="upload-picker"
|
|
31
|
-
>
|
|
3
|
+
<div class="upload-picker">
|
|
32
4
|
<div class="upload-picker__mobile _to-sd">
|
|
33
5
|
<UiBtn
|
|
34
6
|
:disabled="isDisabled"
|
|
@@ -120,7 +92,7 @@
|
|
|
120
92
|
</template>
|
|
121
93
|
|
|
122
94
|
<script setup lang="ts">
|
|
123
|
-
import {
|
|
95
|
+
import { UPLOAD_LIMITS, UploadAccount, UploadCheckStatus } from '../../runtime';
|
|
124
96
|
import type { IUploadFile } from '../../types';
|
|
125
97
|
import { checkUploadFile } from '../../utils/upload/check-upload-file';
|
|
126
98
|
import { createIdleUploadChecks } from '../../utils/upload/create-idle-upload-checks';
|
|
@@ -135,7 +107,7 @@ const emit = defineEmits<{
|
|
|
135
107
|
}>();
|
|
136
108
|
|
|
137
109
|
const { t } = useI18n();
|
|
138
|
-
const {
|
|
110
|
+
const { requireAuth } = useUploadEntry();
|
|
139
111
|
|
|
140
112
|
const inputRef = ref<HTMLInputElement>();
|
|
141
113
|
const isDragging = ref(false);
|
|
@@ -158,7 +130,7 @@ const zoneLabel = computed(() => {
|
|
|
158
130
|
});
|
|
159
131
|
|
|
160
132
|
function openDialog() {
|
|
161
|
-
inputRef.value?.click();
|
|
133
|
+
if (requireAuth()) inputRef.value?.click();
|
|
162
134
|
}
|
|
163
135
|
|
|
164
136
|
async function check(file?: File) {
|
|
@@ -182,7 +154,7 @@ function onDragOver() {
|
|
|
182
154
|
|
|
183
155
|
function onDrop(event: DragEvent) {
|
|
184
156
|
isDragging.value = false;
|
|
185
|
-
check(event.dataTransfer?.files[0]);
|
|
157
|
+
if (requireAuth()) check(event.dataTransfer?.files[0]);
|
|
186
158
|
}
|
|
187
159
|
|
|
188
160
|
function onChange(event: Event) {
|
|
@@ -283,37 +255,4 @@ function onChange(event: Event) {
|
|
|
283
255
|
color: $upload-text;
|
|
284
256
|
font-variant-numeric: tabular-nums;
|
|
285
257
|
}
|
|
286
|
-
|
|
287
|
-
.upload-picker__gate {
|
|
288
|
-
display: flex;
|
|
289
|
-
flex-direction: column;
|
|
290
|
-
justify-content: center;
|
|
291
|
-
align-items: center;
|
|
292
|
-
gap: 16px;
|
|
293
|
-
min-height: 180px;
|
|
294
|
-
padding: 24px;
|
|
295
|
-
text-align: center;
|
|
296
|
-
background-color: $upload-card-bg;
|
|
297
|
-
border-radius: $radius-12;
|
|
298
|
-
|
|
299
|
-
@include from-br(sm) {
|
|
300
|
-
min-height: 220px;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
.upload-picker__gate-icon {
|
|
305
|
-
color: $upload-text;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
.upload-picker__gate-title {
|
|
309
|
-
@include font-base;
|
|
310
|
-
|
|
311
|
-
font-weight: 500;
|
|
312
|
-
color: $upload-title;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
.upload-picker__gate-buttons {
|
|
316
|
-
display: flex;
|
|
317
|
-
gap: 8px;
|
|
318
|
-
}
|
|
319
258
|
</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
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AuthSteps } from '../runtime';
|
|
2
|
+
import { AuthorizationApiService } from '../services/api/authorization.service';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Entry to the upload page for guests: instead of a sign-in placeholder the auth popup opens with an explanation,
|
|
6
|
+
* and after a successful login the user lands on `/upload`. `watchUploadEntry` is called once, by the header.
|
|
7
|
+
*/
|
|
8
|
+
export function useUploadEntry() {
|
|
9
|
+
const { isAuthorized } = useUser(AuthorizationApiService);
|
|
10
|
+
const { isAuthPopupOpen, openAuthPopup } = useAuthPopup();
|
|
11
|
+
const { generateLink } = useGenerateLink();
|
|
12
|
+
const isPending = useState('upload-entry-pending', () => false);
|
|
13
|
+
|
|
14
|
+
function requireAuth() {
|
|
15
|
+
if (isAuthorized.value) return true;
|
|
16
|
+
|
|
17
|
+
isPending.value = true;
|
|
18
|
+
openAuthPopup(AuthSteps.Registration, 'register_to_upload');
|
|
19
|
+
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function watchUploadEntry() {
|
|
24
|
+
watch(isAuthPopupOpen, (isOpen) => {
|
|
25
|
+
if (isOpen || !isPending.value) return;
|
|
26
|
+
|
|
27
|
+
isPending.value = false;
|
|
28
|
+
if (isAuthorized.value) navigateTo(generateLink('/upload'));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { requireAuth, watchUploadEntry };
|
|
33
|
+
}
|
package/i18n/locales/de-DE.json
CHANGED
package/i18n/locales/en-US.json
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
+
"scenes": "Scenes",
|
|
3
|
+
"register_to_upload": "Please sign up or log in to upload your videos",
|
|
2
4
|
"age_gate": {
|
|
3
5
|
"thumb": "18+",
|
|
4
6
|
"title": "Laws in your region require age verification to view 18+ content",
|
|
@@ -58,7 +60,6 @@
|
|
|
58
60
|
"title_details": "Video details",
|
|
59
61
|
"subtitle_pick": "Pick a file. You'll add models and tags while it uploads.",
|
|
60
62
|
"subtitle_details": "Your video keeps uploading while you fill this in.",
|
|
61
|
-
"sign_in": "Sign in to upload",
|
|
62
63
|
"drag": "Drag a video here",
|
|
63
64
|
"drag_another": "Drag another video here",
|
|
64
65
|
"checking_video": "Checking video...",
|
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/package.json
CHANGED
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,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
|
-
}
|