itube-specs 0.0.964 → 0.0.966

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.
@@ -865,6 +865,17 @@ $bg-fade-90: $color-zinc-950-90; // почти непрозрачный
865
865
 
866
866
  // ─── Оверлей карточек ───
867
867
 
868
+ // ─── Player aside models ───
869
+ $aside-models-name: $color-white;
870
+ $aside-models-name--hover: $color-indigo-400;
871
+ $aside-models-params: $color-zinc-400;
872
+ $aside-models-separator: $color-zinc-500;
873
+ $aside-models-count: $color-zinc-500;
874
+ $aside-models-avatar-bg: $color-zinc-800;
875
+ $aside-models-avatar-text: $color-zinc-400;
876
+ $aside-models-avatar-ring: $color-zinc-700;
877
+ $aside-models-avatar-ring--hover: $color-zinc-500;
878
+
868
879
  // ─── Model card ───
869
880
  $model-card-bg: $color-zinc-900-50;
870
881
  $model-card-bg--hover: $color-zinc-800-50;
@@ -891,6 +891,17 @@ $bg-fade-90: $color-gray-950-90; // почти непрозрачный
891
891
 
892
892
  // ─── Оверлей карточек ───
893
893
 
894
+ // ─── Player aside models ───
895
+ $aside-models-name: $color-gray-900;
896
+ $aside-models-name--hover: $color-indigo-600;
897
+ $aside-models-params: $color-gray-600;
898
+ $aside-models-separator: $color-gray-400;
899
+ $aside-models-count: $color-gray-500;
900
+ $aside-models-avatar-bg: $color-gray-200;
901
+ $aside-models-avatar-text: $color-gray-500;
902
+ $aside-models-avatar-ring: $color-gray-300;
903
+ $aside-models-avatar-ring--hover: $color-gray-400;
904
+
894
905
  // ─── Model card ───
895
906
  $model-card-bg: $color-gray-100;
896
907
  $model-card-bg--hover: $model-card-bg;
@@ -0,0 +1,219 @@
1
+ <!-- Блок одной модели ролика в боковой колонке плеера: шапка со ссылкой на модель
2
+ (аватар, имя с флагами, параметры и счётчик роликов) и сетка её роликов 2×2.
3
+ Ролики — обычные мини-карточки, поэтому превью, меню и ссылки у них те же, что
4
+ в остальных сетках. -->
5
+ <template>
6
+ <section class="player-aside-models">
7
+ <NuxtLink
8
+ :to="generateLink(`/models/${slug}`)"
9
+ :title="title"
10
+ class="player-aside-models__head"
11
+ >
12
+ <UiImg
13
+ v-if="avatar"
14
+ :src="avatar"
15
+ :alt="title"
16
+ class="player-aside-models__avatar"
17
+ width="56"
18
+ height="56"
19
+ sizes="56px"
20
+ loading="lazy"
21
+ @error="avatarError = true"
22
+ />
23
+ <span
24
+ v-else
25
+ class="player-aside-models__avatar --empty"
26
+ >{{ title.slice(0, 1) }}</span>
27
+
28
+ <span class="player-aside-models__info">
29
+ <span class="player-aside-models__name-row">
30
+ <span class="player-aside-models__name">{{ title }}</span>
31
+ <!-- Национальность нигде больше не подписана — флаг её единственный носитель,
32
+ поэтому в `alt` идёт название страны, а не ISO-код. -->
33
+ <img
34
+ v-for="code in info?.nationalityIsoCodes || []"
35
+ :key="`flag-${code}`"
36
+ :src="`/icons/flags/${code}.svg`"
37
+ :alt="isoCodeToCountryName(code, locale)"
38
+ class="player-aside-models__flag"
39
+ width="16"
40
+ height="12"
41
+ >
42
+ </span>
43
+ <span class="player-aside-models__params">
44
+ <span
45
+ v-if="parameters"
46
+ class="player-aside-models__params-text"
47
+ >{{ parameters }}</span>
48
+ <span
49
+ v-if="info?.videosCount"
50
+ class="player-aside-models__count"
51
+ >
52
+ <UiIcon
53
+ name="video-camera"
54
+ size="14"
55
+ />
56
+ {{ n(info.videosCount, 'compact') }}
57
+ </span>
58
+ </span>
59
+ </span>
60
+ </NuxtLink>
61
+
62
+ <div
63
+ v-if="videos?.length"
64
+ class="player-aside-models__grid"
65
+ >
66
+ <CardVideo
67
+ v-for="video in videos"
68
+ :key="`aside-model-video-${video.guid}`"
69
+ :card="video"
70
+ class="player-aside-models__card"
71
+ loading="lazy"
72
+ mini
73
+ />
74
+ </div>
75
+ </section>
76
+ </template>
77
+
78
+ <script setup lang="ts">
79
+ import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
80
+ import type { ICardInfo, IVideoCard } from '../../types';
81
+
82
+ /** Что показываем в строке параметров и в каком порядке — как в макете. */
83
+ const PARAMETER_NAMES = ['guy_type', 'ethnicity', 'age'];
84
+
85
+ const props = defineProps<{
86
+ title: string
87
+ slug: string
88
+ info?: ICardInfo | null
89
+ videos?: IVideoCard[]
90
+ }>();
91
+
92
+ const { t, n, locale } = useI18n();
93
+ const { generateLink } = useGenerateLink();
94
+
95
+ const avatarError = ref(false);
96
+ const avatar = computed(() => avatarError.value ? '' : props.info?.img || '');
97
+
98
+ const parameters = computed(() => PARAMETER_NAMES
99
+ .map((name) => {
100
+ const value = props.info?.parameters?.find(item => item.name === name)?.values?.[0]?.title;
101
+ if (!value) return '';
102
+
103
+ return name === 'age' ? `${value} ${t('yo')}` : value;
104
+ })
105
+ .filter(Boolean)
106
+ .join(' · '));
107
+ </script>
108
+
109
+ <style lang="scss">
110
+ .player-aside-models__head {
111
+ display: grid;
112
+ grid-template-columns: auto minmax(0, 1fr);
113
+ column-gap: 12px;
114
+ align-items: center;
115
+ margin-bottom: 16px;
116
+ }
117
+
118
+ .player-aside-models__avatar {
119
+ width: 56px;
120
+ height: 56px;
121
+ object-fit: cover;
122
+ object-position: center 10%;
123
+ background-color: $aside-models-avatar-bg;
124
+ border-radius: $radius-full;
125
+ box-shadow: 0 0 0 2px $aside-models-avatar-ring;
126
+
127
+ @media (hover: hover) {
128
+ transition: var(--transition) box-shadow;
129
+ }
130
+ }
131
+
132
+ .player-aside-models__avatar.--empty {
133
+ display: grid;
134
+ place-content: center;
135
+ font-weight: 700;
136
+ text-transform: uppercase;
137
+ color: $aside-models-avatar-text;
138
+ }
139
+
140
+ @media (hover: hover) {
141
+ .player-aside-models__head:is(:hover, :focus-visible) .player-aside-models__avatar {
142
+ box-shadow: 0 0 0 2px $aside-models-avatar-ring--hover;
143
+ }
144
+
145
+ .player-aside-models__head:is(:hover, :focus-visible) .player-aside-models__name {
146
+ color: $aside-models-name--hover;
147
+ }
148
+ }
149
+
150
+ .player-aside-models__info {
151
+ display: grid;
152
+ row-gap: 2px;
153
+ min-width: 0;
154
+ }
155
+
156
+ .player-aside-models__name-row {
157
+ display: flex;
158
+ align-items: center;
159
+ column-gap: 6px;
160
+ min-width: 0;
161
+ }
162
+
163
+ .player-aside-models__name {
164
+ @include font-base;
165
+
166
+ font-weight: 700;
167
+ color: $aside-models-name;
168
+ white-space: nowrap;
169
+ text-overflow: ellipsis;
170
+ overflow: hidden;
171
+
172
+ @media (hover: hover) {
173
+ transition: var(--transition) color;
174
+ }
175
+ }
176
+
177
+ .player-aside-models__flag {
178
+ flex-shrink: 0;
179
+ border-radius: 2px;
180
+ object-fit: cover;
181
+ }
182
+
183
+ .player-aside-models__params {
184
+ @include font-sm;
185
+
186
+ display: flex;
187
+ align-items: center;
188
+ column-gap: 6px;
189
+ min-width: 0;
190
+ color: $aside-models-params;
191
+ }
192
+
193
+ .player-aside-models__params-text {
194
+ white-space: nowrap;
195
+ text-overflow: ellipsis;
196
+ overflow: hidden;
197
+ }
198
+
199
+ /* Разделитель между параметрами и счётчиком — точка, как в макете */
200
+ .player-aside-models__params-text + .player-aside-models__count::before {
201
+ content: '·';
202
+ color: $aside-models-separator;
203
+ }
204
+
205
+ .player-aside-models__count {
206
+ display: flex;
207
+ align-items: center;
208
+ column-gap: 4px;
209
+ flex-shrink: 0;
210
+ color: $aside-models-count;
211
+ }
212
+
213
+ .player-aside-models__grid {
214
+ display: grid;
215
+ grid-template-columns: repeat(2, minmax(0, 1fr));
216
+ column-gap: 12px;
217
+ row-gap: 16px;
218
+ }
219
+ </style>
@@ -1,14 +1,31 @@
1
- <!-- Боковая колонка справа: рекомендации или реклама -->
1
+ <!-- Боковая колонка справа от плеера: реклама и блоки моделей ролика.
2
+
3
+ У каждой модели своя шапка со ссылкой и сетка её роликов 2×2 (см. PlayerAsideModels
4
+ и useFetchAsideModels). Реклама, если она включена на сайте, стоит ПОД ними — в
5
+ макете её нет, но формат живёт именно в этой колонке.
6
+
7
+ Если моделей у ролика нет (и рекламы тоже), колонка не пустует: показываем пару
8
+ рекомендаций, как было до макета. -->
2
9
  <template>
3
10
  <div
4
11
  :class="[
5
12
  {'_loading': status === 'pending'},
6
13
  ]"
7
- class="player-aside-videos _from-md"
14
+ class="player-aside-videos _from-sd"
8
15
  >
16
+ <PlayerAsideModels
17
+ v-for="item in models || []"
18
+ :key="`aside-model-${item.slug}`"
19
+ :title="item.title"
20
+ :slug="item.slug"
21
+ :info="item.info"
22
+ :videos="item.videos?.items"
23
+ class="player-aside-videos__model"
24
+ />
25
+
9
26
  <AdvNtv v-if="featureFlags.AdsNTVEnabled" />
10
27
 
11
- <template v-else>
28
+ <template v-if="!models?.length && !featureFlags.AdsNTVEnabled">
12
29
  <CardVideo
13
30
  v-for="item in asideVideos?.items?.slice(0, 2)"
14
31
  :key="`aside-video-${item.guid}`"
@@ -20,7 +37,7 @@
20
37
  </template>
21
38
 
22
39
  <script setup lang="ts">
23
- import type { IVideoCard, PaginatedResponse } from '../../types';
40
+ import type { ICardInfo, IVideoCard, PaginatedResponse } from '../../types';
24
41
  import type { AsyncDataRequestStatus } from '#app';
25
42
 
26
43
  const featureFlags = useRuntimeConfig().public.featureFlags;
@@ -28,15 +45,23 @@ const featureFlags = useRuntimeConfig().public.featureFlags;
28
45
  defineProps<{
29
46
  asideVideos?: PaginatedResponse<IVideoCard> | null
30
47
  status?: AsyncDataRequestStatus
48
+ /** Модели ролика с их карточками и роликами (useFetchAsideModels). */
49
+ models?: Array<{
50
+ title: string
51
+ slug: string
52
+ info: ICardInfo | null
53
+ videos: PaginatedResponse<IVideoCard> | null
54
+ }>
31
55
  }>();
32
56
  </script>
33
57
 
34
58
  <style lang="scss">
35
59
  .player-aside-videos {
36
- @include from-br(md) {
60
+ @include from-br(sd) {
37
61
  display: grid;
38
- gap: 16px;
62
+ gap: 32px;
39
63
  grid-template-columns: 1fr;
64
+ width: 288px;
40
65
  height: 100%;
41
66
  grid-column: 2;
42
67
  grid-row: 1;
@@ -61,15 +61,16 @@ const { nextVideo, goNext } = useNextVideo({
61
61
  </script>
62
62
 
63
63
  <style lang="scss">
64
+ /* Боковая колонка встаёт с 1024px — так в макете (до этого плеер во всю ширину). */
64
65
  .player-main {
65
- @include to-br(md) {
66
+ @include to-br(sd) {
66
67
  display: contents;
67
68
  }
68
69
 
69
- @include from-br(md) {
70
+ @include from-br(sd) {
70
71
  display: grid;
71
72
  column-gap: 16px;
72
- grid-template-columns: 1fr auto;
73
+ grid-template-columns: minmax(0, 1fr) auto;
73
74
  }
74
75
  }
75
76
 
@@ -0,0 +1,72 @@
1
+ import type { Ref } from 'vue';
2
+ import type { Language } from '../../runtime';
3
+ import { AsyncData, convertString } from '../../runtime';
4
+ import { ModelsApiService } from '../../services/api/models.service';
5
+ import { VideosApiService } from '../../services/api/videos.service';
6
+ import type { ICardInfo, IVideoCard, PaginatedResponse } from '../../types';
7
+
8
+ /**
9
+ * Сколько моделей ролика показывать в боковой колонке. В макете их две; потолок
10
+ * нужен потому, что каждая модель — это ДВА запроса (инфо + её ролики), и у ролика
11
+ * с длинным составом колонка иначе тянула бы десяток запросов.
12
+ */
13
+ export const ASIDE_MODELS_LIMIT = 3;
14
+
15
+ /** Сколько роликов модели показывать — в макете сетка 2×2. */
16
+ export const ASIDE_MODEL_VIDEOS = 4;
17
+
18
+ /** Порядок роликов модели — тот же, что на её странице по умолчанию. */
19
+ const ORDER = '-popularity';
20
+
21
+ export interface IAsideModel {
22
+ title: string
23
+ /** Слаг для ссылки на страницу модели. */
24
+ slug: string
25
+ info: Ref<ICardInfo | null>
26
+ videos: Ref<PaginatedResponse<IVideoCard> | null>
27
+ }
28
+
29
+ /**
30
+ * Модели ролика для боковой колонки плеера: у каждой — своя карточка (аватар,
31
+ * параметры, счётчик роликов) и первые её ролики.
32
+ *
33
+ * В данных самого ролика у модели есть только `guid` и `title`, поэтому карточку
34
+ * приходится добирать ручками страницы модели. Слаг для ссылки считается из имени
35
+ * так же, как его строят ссылки на модель, а в САМИ ручки уходит не слаг, а имя,
36
+ * прогнанное через `fromSlug` — ровно то, что подставляет страница модели
37
+ * (`useSlug`): бекенд ищет модель по имени, от слага с дефисами он 500-тит.
38
+ */
39
+ export const useFetchAsideModels = async (
40
+ titles: string[],
41
+ limit = ASIDE_MODELS_LIMIT,
42
+ ): Promise<IAsideModel[]> => {
43
+ const lang = useLang() as Language;
44
+
45
+ return await Promise.all(titles.slice(0, limit).map(async (title) => {
46
+ const slug = convertString().toSlug(title);
47
+ const name = convertString().fromSlug(slug);
48
+ const infoKey = `data-${AsyncData.ModelsByName}-aside-${slug}-${lang}`;
49
+ const videosKey = `data-${AsyncData.ModelsVideos}-aside-${slug}-${lang}`;
50
+
51
+ const [{ data: info }, { data: videos }] = await Promise.all([
52
+ useAsyncData<ICardInfo>(
53
+ infoKey,
54
+ () => useApiFetcher<ICardInfo>(infoKey, ModelsApiService.getModelsByName, lang, name)(),
55
+ ),
56
+ useAsyncData<PaginatedResponse<IVideoCard>>(
57
+ videosKey,
58
+ () => useApiFetcher<PaginatedResponse<IVideoCard>>(
59
+ videosKey,
60
+ VideosApiService.getListByNicheAndModel,
61
+ { page: 1, 'per-page': ASIDE_MODEL_VIDEOS },
62
+ {},
63
+ name,
64
+ lang,
65
+ ORDER,
66
+ )(),
67
+ ),
68
+ ]);
69
+
70
+ return { title, slug, info, videos };
71
+ }));
72
+ };
@@ -16,6 +16,9 @@ type AliveResponse = { statuses: WebcamAliveStatus[], replacements: WebcamReplac
16
16
  * Опрашиваем только при видимой вкладке: в фоне это бессмысленный трафик, а при
17
17
  * возврате на вкладку проверяем сразу, потому что за время «сна» модели могли уйти.
18
18
  *
19
+ * Если ручка отвечает 404 (на окружении её просто нет) или трижды подряд падает,
20
+ * проверку выключаем до перезагрузки страницы — нет смысла долбиться каждые 45 секунд.
21
+ *
19
22
  * Проверку арминг ставит ОДИН раз на страницу (`useState`-флаг): список общий, а
20
23
  * потребителей у него два — блок и врезки.
21
24
  *
@@ -42,9 +45,23 @@ export function useWebcamAlive(cacheKey: string) {
42
45
 
43
46
  let timer: ReturnType<typeof setInterval> | null = null;
44
47
  let checking = false;
48
+ let stopped = false;
49
+ let failures = 0;
50
+
51
+ /** После скольких подряд неудач бросаем проверять до перезагрузки страницы. */
52
+ const MAX_FAILURES = 3;
53
+
54
+ function stop() {
55
+ stopped = true;
56
+
57
+ if (!timer) return;
58
+
59
+ clearInterval(timer);
60
+ timer = null;
61
+ }
45
62
 
46
63
  async function check() {
47
- if (checking || document.visibilityState !== 'visible') return;
64
+ if (stopped || checking || document.visibilityState !== 'visible') return;
48
65
 
49
66
  const usernames = models.value.map(model => model.username).filter(Boolean);
50
67
  if (!usernames.length) return;
@@ -58,12 +75,21 @@ export function useWebcamAlive(cacheKey: string) {
58
75
  body: { usernames },
59
76
  });
60
77
 
78
+ failures = 0;
79
+
61
80
  const next = applyWebcamReplacements(models.value, response?.statuses || [], response?.replacements || []);
62
81
  // ссылку меняем только когда список действительно другой: иначе лишний
63
82
  // ререндер сетки на каждой проверке
64
83
  if (next !== models.value) models.value = next;
65
- } catch {
66
- // ручка не ответила — на следующей проверке попробуем снова
84
+ } catch (error) {
85
+ failures += 1;
86
+
87
+ // 404 — ручки на этом окружении нет вовсе (бывает, когда стенд смотрит в
88
+ // API без неё): долбиться каждые 45 секунд незачем. Прочие ошибки могут
89
+ // быть сетевой икотой, поэтому даём три попытки.
90
+ const err = error as { statusCode?: number, status?: number, response?: { status?: number } };
91
+ const status = err?.statusCode ?? err?.status ?? err?.response?.status;
92
+ if (status === 404 || failures >= MAX_FAILURES) stop();
67
93
  } finally {
68
94
  checking = false;
69
95
  }
@@ -79,7 +105,7 @@ export function useWebcamAlive(cacheKey: string) {
79
105
  });
80
106
 
81
107
  onScopeDispose(() => {
82
- if (timer) clearInterval(timer);
108
+ stop();
83
109
  document.removeEventListener('visibilitychange', onVisible);
84
110
  armed.value = false;
85
111
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.964",
4
+ "version": "0.0.966",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -14,6 +14,7 @@
14
14
  <PlayerAsideVideos
15
15
  :aside-videos="relatedVideos"
16
16
  :status="status"
17
+ :models="asideModels"
17
18
  class="video__aside"
18
19
  />
19
20
  </ViewVideo>
@@ -50,6 +51,18 @@ const [
50
51
  useFetchTopModels(ModelsApiService.getTop, 24, AsyncData.TopModelsMore, String(pageName)),
51
52
  ]);
52
53
 
54
+ // Модели ролика для боковой колонки: у каждой своя карточка и её ролики. Запрос
55
+ // зависит от ответа по ролику (имена моделей есть только в нём), поэтому идёт
56
+ // вторым заходом, а не в общем Promise.all.
57
+ const asideModelsData = await useFetchAsideModels(videoData.value?.models?.map(item => item.title) || []);
58
+
59
+ const asideModels = computed(() => asideModelsData.map(item => ({
60
+ title: item.title,
61
+ slug: item.slug,
62
+ info: item.info.value,
63
+ videos: item.videos.value,
64
+ })));
65
+
53
66
  const { t, locale } = useI18n();
54
67
 
55
68
  const defaultLocale = useRuntimeConfig().public.locales.default;
@@ -171,11 +184,3 @@ useHead(computed(() => videoJsonLd.value
171
184
  : {}
172
185
  ));
173
186
  </script>
174
-
175
- <style lang="scss">
176
- .video__aside {
177
- @include from-br(md) {
178
- width: 288px;
179
- }
180
- }
181
- </style>