itube-specs 0.0.991 → 0.0.992

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.
@@ -121,7 +121,7 @@
121
121
  <!-- Страна — в строке имени справа (в макете на узких экранах её нет вовсе). -->
122
122
  <a
123
123
  v-if="model.country"
124
- :href="`${nichePath}/webcams?${WEBCAM_FILTER_QUERY_PREFIX}country=${model.country}`"
124
+ :href="`${nichePath}${webcamsPath}?${WEBCAM_FILTER_QUERY_PREFIX}country=${model.country}`"
125
125
  class="card-webcam__country"
126
126
  >{{ countryName }}</a>
127
127
  </div>
@@ -154,7 +154,7 @@
154
154
  :key="`tag-${tag}`"
155
155
  >
156
156
  <a
157
- :href="`${nichePath}/webcams/${tag}`"
157
+ :href="`${nichePath}${webcamsPath}/${tag}`"
158
158
  class="card-webcam__tag"
159
159
  >{{ tagTitle(tag) }}</a>
160
160
  </li>
@@ -228,9 +228,11 @@ const ageFilterUrl = computed(() => {
228
228
 
229
229
  const prefix = WEBCAM_FILTER_QUERY_PREFIX;
230
230
 
231
- return `${nichePath.value}/webcams?${prefix}age_from=${age - 1}&${prefix}age_to=${age + 1}`;
231
+ return `${nichePath.value}${webcamsPath.value}?${prefix}age_from=${age - 1}&${prefix}age_to=${age + 1}`;
232
232
  });
233
233
 
234
+ const webcamsPath = useWebcamsPath();
235
+
234
236
  const nichePath = computed(() => {
235
237
  const niche = NicheHelper.getSiteDefaultNiche();
236
238
 
@@ -19,7 +19,7 @@
19
19
  <a
20
20
  v-for="item in items"
21
21
  :key="`lang-${item.lang}`"
22
- :href="`${nichePath}/webcams?${WEBCAM_FILTER_QUERY_PREFIX}language=${item.lang}`"
22
+ :href="`${nichePath}${webcamsPath}?${WEBCAM_FILTER_QUERY_PREFIX}language=${item.lang}`"
23
23
  :aria-label="item.lang"
24
24
  class="card-webcam-languages__link"
25
25
  >
@@ -92,6 +92,8 @@ const names = computed(() => {
92
92
 
93
93
  return (props.languages || []).map(lang => display.of(lang) || lang.toUpperCase()).join(', ');
94
94
  });
95
+
96
+ const webcamsPath = useWebcamsPath();
95
97
  </script>
96
98
 
97
99
  <style lang="scss">
@@ -18,18 +18,18 @@
18
18
  >
19
19
  <div class="player-aside-videos__inner">
20
20
  <PlayerAsideModels
21
- v-for="item in models || []"
21
+ v-for="item in shownModels"
22
22
  :key="`aside-model-${item.slug}`"
23
23
  :title="item.title"
24
24
  :slug="item.slug"
25
25
  :info="item.info"
26
- :videos="item.videos?.items"
26
+ :videos="item.videos"
27
27
  class="player-aside-videos__model"
28
28
  />
29
29
 
30
30
  <AdvNtv v-if="featureFlags.AdsNTVEnabled" />
31
31
 
32
- <template v-if="!models?.length && !featureFlags.AdsNTVEnabled">
32
+ <template v-if="!shownModels.length && !featureFlags.AdsNTVEnabled">
33
33
  <CardVideo
34
34
  v-for="item in asideVideos?.items?.slice(0, 2)"
35
35
  :key="`aside-video-${item.guid}`"
@@ -47,7 +47,7 @@ import type { AsyncDataRequestStatus } from '#app';
47
47
 
48
48
  const featureFlags = useRuntimeConfig().public.featureFlags;
49
49
 
50
- defineProps<{
50
+ const props = defineProps<{
51
51
  asideVideos?: PaginatedResponse<IVideoCard> | null
52
52
  status?: AsyncDataRequestStatus
53
53
  /** Модели ролика с их карточками и роликами (useFetchAsideModels). */
@@ -55,9 +55,15 @@ defineProps<{
55
55
  title: string
56
56
  slug: string
57
57
  info: ICardInfo | null
58
- videos: PaginatedResponse<IVideoCard> | null
58
+ videos: IVideoCard[]
59
59
  }>
60
60
  }>();
61
+
62
+ /**
63
+ * Блок получает только та модель, чьих роликов хватает на полную строку сетки —
64
+ * список уже подрезан в `useFetchAsideModels`. Остальные уходят чипсами над плеером.
65
+ */
66
+ const shownModels = computed(() => (props.models || []).filter(model => model.videos.length));
61
67
  </script>
62
68
 
63
69
  <style lang="scss">
@@ -22,6 +22,7 @@ query-параметрах `filter_*`. Формат провайдера при
22
22
  :items="sections"
23
23
  :model-value="'webcams'"
24
24
  class="view-webcams__sections"
25
+ @update:model-value="resetSection"
25
26
  />
26
27
  </div>
27
28
 
@@ -238,6 +239,20 @@ const sections = computed<ISwitcherItem[]>(() => [
238
239
  },
239
240
  ]);
240
241
 
242
+ /**
243
+ * Клик по уже активному разделу — это «показать раздел целиком»: снимаем фильтры и
244
+ * страницу пагинации, всё это живёт в query. Тег остаётся: он часть пути, а не фильтр.
245
+ * Query пуст — раздел и так целиком, и клик ничего не делает.
246
+ *
247
+ * Навигация именно `path` + `query`, а не `{ query }`: у location без пути роутер
248
+ * берёт текущий адрес вместе с параметрами и на нишевых страницах терял бы префикс.
249
+ */
250
+ function resetSection() {
251
+ if (!Object.keys(route.query).length) return;
252
+
253
+ router.replace({ path: route.path, query: {} });
254
+ }
255
+
241
256
  /**
242
257
  * Вход в раздел: сразу показываем моделей, которые говорят на языке пользователя.
243
258
  * Только при первом заходе за сессию — иначе снятый пользователем фильтр
@@ -61,7 +61,9 @@ const promoLink = computed(() => {
61
61
  return links[niche.value] || links['straight'] || '';
62
62
  });
63
63
  const isPromoBlocked = ref(false);
64
- const promoHref = computed(() => (isPromoBlocked.value ? `${nichePath.value}/webcams` : promoLink.value));
64
+ const webcamsPath = useWebcamsPath();
65
+
66
+ const promoHref = computed(() => (isPromoBlocked.value ? `${nichePath.value}${webcamsPath.value}` : promoLink.value));
65
67
 
66
68
  const promoRef = ref<HTMLElement | null>(null);
67
69
 
@@ -1,6 +1,6 @@
1
1
  import type { Ref } from 'vue';
2
2
  import type { Language } from '../../runtime';
3
- import { AsyncData, convertString } from '../../runtime';
3
+ import { AsyncData, asideModelVideos, convertString } from '../../runtime';
4
4
  import { ModelsApiService } from '../../services/api/models.service';
5
5
  import { VideosApiService } from '../../services/api/videos.service';
6
6
  import type { ICardInfo, IVideoCard, PaginatedResponse } from '../../types';
@@ -15,6 +15,13 @@ export const ASIDE_MODELS_LIMIT = 3;
15
15
  /** Сколько роликов модели показывать — в макете сетка 2×2. */
16
16
  export const ASIDE_MODEL_VIDEOS = 4;
17
17
 
18
+ /**
19
+ * Слаги моделей, у которых блок в колонке есть. Кому роликов не хватило, тот
20
+ * остаётся чипсой над плеером на всех ширинах — берут этот список те, кто чипсы
21
+ * рисует.
22
+ */
23
+ export const useAsideModelSlugs = () => useState<string[]>('aside-model-slugs', () => []);
24
+
18
25
  /** Порядок роликов модели — тот же, что на её странице по умолчанию. */
19
26
  const ORDER = '-popularity';
20
27
 
@@ -23,7 +30,8 @@ export interface IAsideModel {
23
30
  /** Слаг для ссылки на страницу модели. */
24
31
  slug: string
25
32
  info: Ref<ICardInfo | null>
26
- videos: Ref<PaginatedResponse<IVideoCard> | null>
33
+ /** Ролики модели, уже подрезанные под сетку 2×2 (`asideModelVideos`). */
34
+ videos: Ref<IVideoCard[]>
27
35
  }
28
36
 
29
37
  /**
@@ -41,8 +49,9 @@ export const useFetchAsideModels = async (
41
49
  limit = ASIDE_MODELS_LIMIT,
42
50
  ): Promise<IAsideModel[]> => {
43
51
  const lang = useLang() as Language;
52
+ const shown = useAsideModelSlugs();
44
53
 
45
- return await Promise.all(titles.slice(0, limit).map(async (title) => {
54
+ const models = await Promise.all(titles.slice(0, limit).map(async (title) => {
46
55
  const slug = convertString().toSlug(title);
47
56
  const name = convertString().fromSlug(slug);
48
57
  const infoKey = `data-${AsyncData.ModelsByName}-aside-${slug}-${lang}`;
@@ -67,6 +76,15 @@ export const useFetchAsideModels = async (
67
76
  ),
68
77
  ]);
69
78
 
70
- return { title, slug, info, videos };
79
+ // Список режем здесь, а не в разметке: решение про нечётный хвост общее для
80
+ // блока и для чипсов, и принимать его дважды в разных местах — расходиться.
81
+ return { title, slug, info, videos: computed(() => asideModelVideos(videos.value?.items, ASIDE_MODEL_VIDEOS)) };
71
82
  }));
83
+
84
+ // Кому роликов хватило — тот в колонке; остальные остаются чипсами над плеером.
85
+ watchEffect(() => {
86
+ shown.value = models.filter(model => model.videos.value.length).map(model => model.slug);
87
+ });
88
+
89
+ return models;
72
90
  };
@@ -0,0 +1,14 @@
1
+ import { WEBCAMS_DEFAULTS } from '../runtime';
2
+ import type { IWebcamsConfig } from '../types';
3
+
4
+ /**
5
+ * Адрес раздела вебкамов без нишевого префикса. Сайт задаёт его в
6
+ * `webcamsConfig.path` — раздел может жить на своём URL (например `/live-sex`),
7
+ * и все ссылки на фильтры, теги и промо обязаны вести туда же, иначе каждая
8
+ * уходит через редирект.
9
+ */
10
+ export function useWebcamsPath() {
11
+ const config = useRuntimeConfig().public.webcamsConfig as IWebcamsConfig | undefined;
12
+
13
+ return computed(() => config?.path || WEBCAMS_DEFAULTS.path);
14
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.991",
4
+ "version": "0.0.992",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -29,6 +29,8 @@ import { sortWebcams } from '../../lib';
29
29
  const { t } = useI18n();
30
30
  const route = useRoute();
31
31
 
32
+ const webcamsPath = useWebcamsPath();
33
+
32
34
  /** Тег — сегмент пути (`/webcams/{tag}`), как на боевом движке. */
33
35
  const tag = computed(() => String(route.params.tag || ''));
34
36
 
@@ -63,7 +65,7 @@ const breadcrumbs = computed<IBreadcrumbItem[]>(() => [
63
65
  to: '/',
64
66
  },
65
67
  ...(tag.value
66
- ? [{ name: t('navigation.webcams'), to: '/webcams' }, { name: tagTitle.value }]
68
+ ? [{ name: t('navigation.webcams'), to: webcamsPath.value }, { name: tagTitle.value }]
67
69
  : [{ name: t('navigation.webcams') }]),
68
70
  ]);
69
71
  </script>
@@ -6,6 +6,12 @@
6
6
  export const WEBCAMS_DEFAULTS = {
7
7
  /** Ключ адаптера провайдера (см. `utils/webcams/adapters.ts`). */
8
8
  provider: 'stripcash',
9
+ /**
10
+ * Адрес раздела без нишевого префикса. Сайт может держать его на своём URL
11
+ * (`/live-sex`), и тогда ссылки на фильтры, теги и промо должны вести туда же.
12
+ * Сам роут переименовывает сайт у себя в `pages:extend`.
13
+ */
14
+ path: '/webcams',
9
15
  /**
10
16
  * Сколько моделей просить. 12 не случайно: сетка повторяет основной грид
11
17
  * (2 / 3 / 4 колонки по брейкпоинтам), и 12 делится на каждое из них без
package/runtime/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from './constants/report-forms-subjects';
9
9
  export * from './constants/playlist-step';
10
10
  export * from './constants/thumb-size';
11
11
  export * from './constants/webcams';
12
+ export * from './utils/aside-model-videos';
12
13
  export * from './utils/build-srcset';
13
14
  export * from './utils/pick-portrait-variant';
14
15
  export * from './utils/format-date';
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { asideModelVideos } from '../aside-model-videos';
3
+
4
+ const list = (n: number) => Array.from({ length: n }, (_, i) => i);
5
+
6
+ describe('asideModelVideos', () => {
7
+ it('чётное число показываем как есть', () => {
8
+ expect(asideModelVideos(list(4))).toEqual([0, 1, 2, 3]);
9
+ expect(asideModelVideos(list(2))).toEqual([0, 1]);
10
+ });
11
+
12
+ it('нечётное режем до чётного — иначе в сетке 2×2 дырка', () => {
13
+ expect(asideModelVideos(list(3))).toEqual([0, 1]);
14
+ expect(asideModelVideos(list(5))).toEqual([0, 1, 2, 3]);
15
+ });
16
+
17
+ it('меньше двух — блока нет, модель уйдёт чипсой', () => {
18
+ expect(asideModelVideos(list(1))).toEqual([]);
19
+ expect(asideModelVideos([])).toEqual([]);
20
+ expect(asideModelVideos(undefined)).toEqual([]);
21
+ });
22
+
23
+ it('потолок не превышаем', () => {
24
+ expect(asideModelVideos(list(9))).toHaveLength(4);
25
+ expect(asideModelVideos(list(9), 2)).toHaveLength(2);
26
+ });
27
+ });
@@ -18,6 +18,27 @@ describe('pickPortraitVariant', () => {
18
18
  expect(pickPortraitVariant({ light: { tall: { ...tall, webp: 'light' } } })?.tall?.webp).toBe('light');
19
19
  });
20
20
 
21
+ it('задвоенный слэш в пути схлопывается — иначе CDN отвечает 301 на каждую тумбу', () => {
22
+ const portraits = {
23
+ dark: {
24
+ tall: { width: 640, height: 960, webp: 'https://nli02.joystorage.com//7/e/0/a.webp', avif: 'https://nli02.joystorage.com//7/e/0/a.avif' },
25
+ square: { width: 256, height: 256, webp: 'https://nli01.joystorage.com//f/0/5/b.webp', avif: '' },
26
+ },
27
+ };
28
+ const result = pickPortraitVariant(portraits);
29
+
30
+ expect(result?.tall?.webp).toBe('https://nli02.joystorage.com/7/e/0/a.webp');
31
+ expect(result?.tall?.avif).toBe('https://nli02.joystorage.com/7/e/0/a.avif');
32
+ expect(result?.square?.webp).toBe('https://nli01.joystorage.com/f/0/5/b.webp');
33
+ expect(result?.square?.avif).toBe('');
34
+ });
35
+
36
+ it('нормальную ссылку не трогаем — протокольные слэши на месте', () => {
37
+ const portraits = { dark: { tall: { ...tall, webp: 'https://cache1.itube.dev/0/1/2/a.webp' } } };
38
+
39
+ expect(pickPortraitVariant(portraits)?.tall?.webp).toBe('https://cache1.itube.dev/0/1/2/a.webp');
40
+ });
41
+
21
42
  it('пусто → undefined', () => {
22
43
  expect(pickPortraitVariant()).toBeUndefined();
23
44
  expect(pickPortraitVariant({})).toBeUndefined();
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Сколько роликов модели показать в боковой колонке плеера.
3
+ *
4
+ * Сетка там в две колонки, поэтому нечётный хвост оставлял бы дырку: при трёх
5
+ * роликах показываем два. Если даже на одну строку не набирается (меньше двух),
6
+ * блока у модели нет вовсе — она уходит чипсой над плеером.
7
+ *
8
+ * @param videos - ролики модели, как их вернула ручка
9
+ * @param limit - потолок выдачи блока (в макете 2×2)
10
+ */
11
+ export const asideModelVideos = <T>(videos: T[] | undefined, limit = 4): T[] => {
12
+ const count = Math.min(videos?.length || 0, limit);
13
+ const even = count - (count % 2);
14
+
15
+ return even < 2 ? [] : (videos as T[]).slice(0, even);
16
+ };
@@ -1,4 +1,18 @@
1
- import type { IRawPortraits, IRawPortraitVariant } from '../../types';
1
+ import type { IRawPortraits, IRawPortraitFile, IRawPortraitVariant } from '../../types';
2
+
3
+ /**
4
+ * Схлопывает задвоенный слэш в пути. Прод-сборщик ссылок на портреты клеит хост со
5
+ * слэшем на конце с путём со слэшем в начале и отдаёт `https://host//a/b/c.avif`.
6
+ * CDN такой адрес принимает, но отвечает 301 на нормальный — то есть каждая тумба
7
+ * стоит лишнего похода, а на части узлов этот поход срывается в 502.
8
+ */
9
+ const normalizeUrl = (url?: string): string => (url || '').replace(/([^:])\/{2,}/g, '$1/');
10
+
11
+ const normalizeFile = (file?: IRawPortraitFile): IRawPortraitFile | undefined => (file && {
12
+ ...file,
13
+ webp: normalizeUrl(file.webp),
14
+ avif: normalizeUrl(file.avif),
15
+ });
2
16
 
3
17
  /**
4
18
  * Выбирает вариант портрета из карты, которую отдаёт API. Ключ — фон съёмки.
@@ -11,8 +25,8 @@ import type { IRawPortraits, IRawPortraitVariant } from '../../types';
11
25
  export const pickPortraitVariant = (portraits?: IRawPortraits, preferred?: string): IRawPortraitVariant | undefined => {
12
26
  if (!portraits) return undefined;
13
27
 
14
- if (preferred && portraits[preferred]) return portraits[preferred];
15
- if (portraits['dark']) return portraits['dark'];
28
+ const variant = (preferred && portraits[preferred]) || portraits['dark'] || Object.values(portraits)[0];
29
+ if (!variant) return undefined;
16
30
 
17
- return Object.values(portraits)[0];
31
+ return { tall: normalizeFile(variant.tall), square: normalizeFile(variant.square) };
18
32
  };
@@ -5,6 +5,8 @@
5
5
  */
6
6
  export interface IWebcamsConfig {
7
7
  provider?: string;
8
+ /** Адрес раздела без нишевого префикса; по умолчанию `/webcams`. */
9
+ path?: string;
8
10
  limit?: number;
9
11
  previewQuality?: string[];
10
12
  /** Период перепроверки онлайна показанных моделей, мс; 0 — не проверять. */