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.
Files changed (33) hide show
  1. package/SITE_UPDATES.md +22 -0
  2. package/assets/scss/themes/dark/_theme.scss +14 -0
  3. package/assets/scss/themes/light/_theme.scss +14 -0
  4. package/components/layout/burger.vue +4 -5
  5. package/components/layout/header.vue +8 -0
  6. package/components/player/core.vue +69 -2
  7. package/components/player/scenes.vue +138 -0
  8. package/components/upload/main.vue +1 -7
  9. package/components/upload/picker.vue +6 -67
  10. package/components/view/video.vue +9 -4
  11. package/composables/use-upload-entry.ts +33 -0
  12. package/i18n/locales/de-DE.json +1 -0
  13. package/i18n/locales/en-US.json +2 -1
  14. package/i18n/locales/es-ES.json +1 -0
  15. package/i18n/locales/fr-FR.json +1 -0
  16. package/i18n/locales/it-IT.json +1 -0
  17. package/i18n/locales/ja-JP.json +1 -0
  18. package/i18n/locales/ko-KR.json +1 -0
  19. package/i18n/locales/ru-RU.json +1 -0
  20. package/i18n/locales/zh-CN.json +1 -0
  21. package/package.json +1 -1
  22. package/runtime/constants/upload.ts +0 -1
  23. package/runtime/index.ts +0 -1
  24. package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +17 -0
  25. package/runtime/utils/cleaners/clean-video-data.ts +8 -1
  26. package/runtime/utils/converters/convert-tag-group-item-to-chips.ts +1 -1
  27. package/server/api/videos/get-video-by-id/[id].get.ts +3 -4
  28. package/server/api/videos/get-video-by-url/[url].get.ts +3 -4
  29. package/types/raw/raw-video-data.d.ts +0 -2
  30. package/types/video-data.d.ts +4 -3
  31. package/runtime/utils/__tests__/video-data-mark-top-categories.test.ts +0 -19
  32. package/runtime/utils/video-data-mark-top-categories.ts +0 -21
  33. 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="siteHooks.onReady?.()"
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: sign-in gate, drop zone (desktop) or file button (mobile), requirement checks of the picked file -->
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 { AuthSteps, UPLOAD_LIMITS, UploadAccount, UploadCheckStatus } from '../../runtime';
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 { openAuthPopup } = useAuthPopup();
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
- // Над плеером — только топ-категории (`isTop` ставит BFF); без пометки (топ-список не пришёл) — все.
243
- // Теги, совпавшие с нетоповой категорией, всё равно отсекаются по полному списку ниже.
244
- const topCategoriesChips = computed((): IChipsItem[] => (categoriesChips.value ?? [])
245
- .filter((_, index) => props.data.categories[index]?.isTop !== false));
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
+ }
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "Szenen",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ Schau und lade VOLLE Videos in HD & 4K 🔥",
4
5
  "promo_action": "ZUGANG ERHALTEN"
@@ -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...",
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "Escenas",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ Mira y descarga vídeos COMPLETOS en HD y 4K 🔥",
4
5
  "promo_action": "OBTENER ACCESO"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "Scènes",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ Regardez et téléchargez des vidéos COMPLÈTES en HD et 4K 🔥",
4
5
  "promo_action": "OBTENIR L’ACCÈS"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "Scene",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ Guarda e scarica video COMPLETI in HD e 4K 🔥",
4
5
  "promo_action": "OTTIENI ACCESSO"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "シーン",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ 動画のフル版をHD・4K画質で視聴・ダウンロード 🔥",
4
5
  "promo_action": "アクセスする"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "장면",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ 영상 전체를 HD·4K 화질로 감상하고 다운로드 🔥",
4
5
  "promo_action": "액세스 받기"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "Сцены",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ Смотрите и скачивайте ПОЛНЫЕ видео в качестве HD и 4K 🔥",
4
5
  "promo_action": "ПОЛУЧИТЬ ДОСТУП"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "场景",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ 观看并下载完整视频,支持 HD 和 4K 🔥",
4
5
  "promo_action": "获取访问权限"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1072",
4
+ "version": "0.0.1074",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -18,7 +18,6 @@ export type UploadStatus = typeof UploadStatus[keyof typeof UploadStatus];
18
18
 
19
19
  export const UploadAccount = {
20
20
  Ok: 'ok',
21
- SignedOut: 'signed-out',
22
21
  Limit: 'limit',
23
22
  } as const;
24
23
 
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, videoDataMarkTopCategories, getUrlWithProxiedParams } from '../../../../runtime';
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, topCategoryNames] = await Promise.all([
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 = videoDataMarkTopCategories(withDemoFallbacks(videoRaw.data as IRawVideoData), topCategoryNames);
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, videoDataMarkTopCategories, getUrlWithProxiedParams } from '../../../../runtime';
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, topCategoryNames] = await Promise.all([
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 = videoDataMarkTopCategories(withDemoFallbacks(videoRaw.data as IRawVideoData), topCategoryNames);
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(
@@ -37,8 +37,6 @@ export interface IRawVideoData {
37
37
  state?: string,
38
38
  title: string,
39
39
  unknownFields?: string,
40
- /** Не из API: ставит BFF по топ-списку категорий. */
41
- isTop?: boolean
42
40
  }[];
43
41
  actions: null;
44
42
  primary_thumb_rank: number; // unused
@@ -30,7 +30,8 @@ export interface ISceneGroupScene {
30
30
  }
31
31
 
32
32
  /** Тип узла группы (контракт ручки видео v2): по нему строится ссылка элементов — тег, категория или поиск. */
33
- export type VideoTagGroupType = 'tag' | 'category' | 'search';
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
- }