itube-specs 0.0.998 → 0.0.1001

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 (115) hide show
  1. package/components/card/channel.vue +6 -3
  2. package/components/card/model-mini.vue +3 -2
  3. package/components/card/model.vue +3 -2
  4. package/components/card/playlist.vue +3 -0
  5. package/components/card/round.vue +6 -2
  6. package/components/card/webcam/index.vue +1 -5
  7. package/components/info/model.vue +4 -4
  8. package/components/layout/footer.vue +30 -1
  9. package/components/layout/header-user.vue +4 -2
  10. package/components/layout/header.vue +5 -1
  11. package/components/layout/language.vue +3 -0
  12. package/components/player/interface.vue +2 -0
  13. package/components/search/item.vue +3 -2
  14. package/components/search/result.vue +1 -0
  15. package/components/section/categories-island.server.vue +6 -2
  16. package/components/ui/pagination.vue +5 -3
  17. package/components/view/model-videos.vue +0 -2
  18. package/components/view/models.vue +0 -2
  19. package/components/view/video.vue +6 -4
  20. package/components/view/videos.vue +2 -2
  21. package/components/webcam/header.vue +2 -2
  22. package/components/webcam/stripcash-card.vue +1 -1
  23. package/composables/__tests__/use-seo-links.test.ts +62 -1
  24. package/composables/fetch/use-fetch-aside-models.ts +5 -3
  25. package/composables/fetch/use-fetch-models-count.ts +1 -1
  26. package/composables/fetch/use-fetch-webcams.ts +1 -1
  27. package/composables/use-generate-link.ts +11 -6
  28. package/composables/use-lang-choice.ts +32 -0
  29. package/composables/use-meta.ts +42 -2
  30. package/composables/use-model-placeholder.ts +10 -0
  31. package/composables/use-niches.ts +9 -1
  32. package/composables/use-seo-links.ts +71 -17
  33. package/composables/use-seo-overrides.ts +7 -1
  34. package/composables/use-webcam-alive.ts +1 -1
  35. package/config/__tests__/locale-domains.test.ts +53 -0
  36. package/config/__tests__/niches.test.ts +69 -0
  37. package/config/index.ts +2 -0
  38. package/config/locale-domains.ts +95 -0
  39. package/config/niches.ts +97 -0
  40. package/lib/scheme/navigation-items.ts +3 -1
  41. package/lib/scheme/profile-navigation-items.ts +5 -3
  42. package/middleware/feature-flags.global.ts +13 -0
  43. package/middleware/niche.global.ts +23 -0
  44. package/nuxt.config.ts +8 -2
  45. package/package.json +1 -1
  46. package/pages/categories/[slug].vue +0 -2
  47. package/pages/channels/[channel].vue +0 -2
  48. package/pages/following.vue +3 -2
  49. package/pages/history.vue +2 -0
  50. package/pages/library/categories.vue +2 -0
  51. package/pages/library/channels.vue +2 -0
  52. package/pages/library/favorites.vue +2 -0
  53. package/pages/library/index.vue +15 -12
  54. package/pages/library/models.vue +2 -0
  55. package/pages/library/playlists.vue +2 -0
  56. package/pages/library/watch-later.vue +2 -0
  57. package/pages/search/[slug]/index.vue +1 -5
  58. package/plugins/i18n-domains.ts +33 -0
  59. package/public/img/model-placeholder/gay-dark-portrait.avif +0 -0
  60. package/public/img/model-placeholder/gay-dark-portrait.webp +0 -0
  61. package/public/img/model-placeholder/gay-dark-square.avif +0 -0
  62. package/public/img/model-placeholder/gay-dark-square.webp +0 -0
  63. package/public/img/model-placeholder/gay-light-portrait.avif +0 -0
  64. package/public/img/model-placeholder/gay-light-portrait.webp +0 -0
  65. package/public/img/model-placeholder/gay-light-square.avif +0 -0
  66. package/public/img/model-placeholder/gay-light-square.webp +0 -0
  67. package/public/img/model-placeholder/shemale-dark-portrait.avif +0 -0
  68. package/public/img/model-placeholder/shemale-dark-portrait.webp +0 -0
  69. package/public/img/model-placeholder/shemale-dark-square.avif +0 -0
  70. package/public/img/model-placeholder/shemale-dark-square.webp +0 -0
  71. package/public/img/model-placeholder/shemale-light-portrait.avif +0 -0
  72. package/public/img/model-placeholder/shemale-light-portrait.webp +0 -0
  73. package/public/img/model-placeholder/shemale-light-square.avif +0 -0
  74. package/public/img/model-placeholder/shemale-light-square.webp +0 -0
  75. package/public/img/model-placeholder/straight-dark-portrait.avif +0 -0
  76. package/public/img/model-placeholder/straight-dark-portrait.webp +0 -0
  77. package/public/img/model-placeholder/straight-dark-square.avif +0 -0
  78. package/public/img/model-placeholder/straight-dark-square.webp +0 -0
  79. package/public/img/model-placeholder/straight-light-portrait.avif +0 -0
  80. package/public/img/model-placeholder/straight-light-portrait.webp +0 -0
  81. package/public/img/model-placeholder/straight-light-square.avif +0 -0
  82. package/public/img/model-placeholder/straight-light-square.webp +0 -0
  83. package/runtime/index.ts +1 -0
  84. package/runtime/utils/__tests__/model-placeholder.test.ts +32 -0
  85. package/runtime/utils/__tests__/video-data-add-model-portraits.test.ts +7 -0
  86. package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +4 -3
  87. package/runtime/utils/cleaners/clean-model-card.ts +2 -0
  88. package/runtime/utils/cleaners/clean-model-info.ts +2 -0
  89. package/runtime/utils/cleaners/clean-video-data.ts +2 -0
  90. package/runtime/utils/converters/convert-model-to-round-card.ts +1 -0
  91. package/runtime/utils/model-placeholder.ts +44 -0
  92. package/runtime/utils/video-data-add-model-portraits.ts +7 -4
  93. package/server/middleware/locale-redirect.ts +25 -0
  94. package/server/utils/__tests__/locale-redirect.test.ts +50 -0
  95. package/server/utils/locale-redirect.ts +89 -0
  96. package/services/api/categories.service.ts +10 -10
  97. package/services/api/channels.service.ts +4 -4
  98. package/services/api/models.service.ts +12 -12
  99. package/services/api/playlists.service.ts +5 -5
  100. package/services/api/search.service.ts +2 -2
  101. package/services/api/videos.service.ts +24 -24
  102. package/services/api/webcams.service.ts +1 -1
  103. package/types/card-info.d.ts +2 -0
  104. package/types/model-card.d.ts +2 -0
  105. package/types/raw/raw-model-card.d.ts +2 -0
  106. package/types/raw/raw-model-info.d.ts +2 -0
  107. package/types/raw/raw-video-data.d.ts +3 -0
  108. package/types/round-card.d.ts +2 -0
  109. package/types/search-top-models.d.ts +2 -0
  110. package/types/video-data.d.ts +2 -0
  111. package/utils/__tests__/niche-post-translation.test.ts +13 -0
  112. package/utils/adverts-helper.ts +3 -3
  113. package/utils/banner-helper.ts +1 -1
  114. package/utils/niche-helper.ts +21 -0
  115. package/utils/niche-post-translation.ts +63 -0
@@ -93,15 +93,18 @@ type InfoItem = {
93
93
  icon: string
94
94
  }
95
95
 
96
+ const featureFlags = useRuntimeConfig().public.featureFlags;
97
+
96
98
  const infoItems = computed<InfoItem[]>(() => [
97
99
  {
98
100
  text: props.card.videosCount,
99
101
  icon: 'video-camera'
100
102
  },
101
- {
102
- text: n(95000, 'compact'), // TODO тестовое
103
+ // число подписчиков — заглушка, ручки нет: живёт вместе с подписками
104
+ ...(featureFlags.FeatureFollowEnabled ? [{
105
+ text: n(95000, 'compact'),
103
106
  icon: 'double-user'
104
- },
107
+ }] : []),
105
108
  {
106
109
  text: formatDate(props.card.updated),
107
110
  icon: 'date'
@@ -52,8 +52,9 @@ const props = defineProps<{
52
52
  card: IModelCard
53
53
  }>();
54
54
 
55
- const fallbackImage = '/img/model-placeholder-square.webp';
56
- const fallbackAvifImage = '/img/model-placeholder-square.avif';
55
+ const placeholder = useModelPlaceholder()(props.card.gender, 'square');
56
+ const fallbackImage = placeholder.webp;
57
+ const fallbackAvifImage = placeholder.avif;
57
58
  const imageError = ref(false);
58
59
 
59
60
  /** Аватар 48px круглый — нужен квадратный кроп, вертикальный портрет сюда не годится. */
@@ -98,8 +98,9 @@ const { t } = useI18n();
98
98
  */
99
99
  const SIZES = 'auto, (max-width: 449px) 50vw, (max-width: 1385px) 215px, 250px';
100
100
 
101
- const fallbackImage = '/img/model-placeholder-portrait.webp';
102
- const fallbackAvifImage = '/img/model-placeholder-portrait.avif';
101
+ const placeholder = useModelPlaceholder()(props.card.gender, 'portrait');
102
+ const fallbackImage = placeholder.webp;
103
+ const fallbackAvifImage = placeholder.avif;
103
104
  const imageError = ref(false);
104
105
 
105
106
  const isFallback = computed(() => imageError.value || !props.card.primaryImageUrl);
@@ -172,6 +172,7 @@
172
172
  </template>
173
173
  <template v-else>
174
174
  <button
175
+ v-if="featureFlags.FeatureFollowEnabled"
175
176
  :class="className"
176
177
  type="button"
177
178
  @click="toggleFollow(card.id, card.name)"
@@ -257,6 +258,8 @@ const userProfileData = computed(() => useState<IProfileData>('profileData').val
257
258
 
258
259
  const { isAuthorized } = useUser(AuthorizationApiService);
259
260
 
261
+ const featureFlags = useRuntimeConfig().public.featureFlags;
262
+
260
263
  const { isFollowing, toggleFollow, toggle } = usePlaylistFollow();
261
264
 
262
265
  const { openPlaylistEdit } = usePlaylistEdit();
@@ -63,7 +63,11 @@ const { generateLink } = useGenerateLink();
63
63
  /** `--card-round-size`: 80px до sm, 96px дальше. `auto` впереди — см. `UiImg`. */
64
64
  const SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
65
65
 
66
- const fallbackImage = props.placeholderAvatar;
66
+ // У карточек моделей есть `gender` (пусть и пустой) — им заглушка по полу и нише, остальным — из пропа.
67
+ const modelFallback = props.card.gender === undefined
68
+ ? null
69
+ : useModelPlaceholder()(props.card.gender, 'square');
70
+ const fallbackImage = modelFallback?.webp || props.placeholderAvatar;
67
71
  const imageError = ref(false);
68
72
 
69
73
  const isFallback = computed(() => imageError.value || !props.card.primaryImageUrl);
@@ -73,7 +77,7 @@ const currentImage = computed(() => (
73
77
  ));
74
78
 
75
79
  const currentAvifSrc = computed(() => (
76
- isFallback.value ? props.placeholderAvatarAvif : props.card.primaryImageAvifUrl
80
+ isFallback.value ? modelFallback?.avif || props.placeholderAvatarAvif : props.card.primaryImageAvifUrl
77
81
  ));
78
82
 
79
83
  function onImageError() {
@@ -233,11 +233,7 @@ const ageFilterUrl = computed(() => {
233
233
 
234
234
  const webcamsPath = useWebcamsPath();
235
235
 
236
- const nichePath = computed(() => {
237
- const niche = NicheHelper.getSiteDefaultNiche();
238
-
239
- return niche && niche !== 'straight' ? `/${niche}` : '';
240
- });
236
+ const nichePath = computed(() => NicheHelper.getNichePath());
241
237
 
242
238
  // Короткая форма («США», а не «Соединенные Штаты») теперь дефолт утилиты — своего
243
239
  // вызова ICU карточке больше не надо
@@ -151,19 +151,19 @@ const props = defineProps<{
151
151
  const { t, locale } = useI18n();
152
152
 
153
153
  const featureFlags = useRuntimeConfig().public.featureFlags;
154
+ const placeholderOf = useModelPlaceholder();
154
155
 
155
156
  const KNOWN_ICONS = ['facebook', 'instagram', 'tiktok', 'twitter', 'x', 'youtube', 'snapchat'];
156
157
 
157
- const PLACEHOLDER = '/img/model-placeholder-portrait.webp';
158
- const PLACEHOLDER_AVIF = '/img/model-placeholder-portrait.avif';
158
+ const placeholder = computed(() => placeholderOf(props.card.gender, 'portrait'));
159
159
 
160
160
  /** `.info-model__img` — 160px до sm, 240px дальше. `auto` впереди — см. `UiImg`. */
161
161
  const IMG_SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 160px, 240px`;
162
162
 
163
163
  const imageError = ref(false);
164
164
  const isFallback = computed(() => imageError.value || !props.card.portraitImg);
165
- const currentImage = computed(() => (isFallback.value ? PLACEHOLDER : props.card.portraitImg));
166
- const currentAvifSrc = computed(() => (isFallback.value ? PLACEHOLDER_AVIF : props.card.portraitAvifUrl));
165
+ const currentImage = computed(() => (isFallback.value ? placeholder.value.webp : props.card.portraitImg));
166
+ const currentAvifSrc = computed(() => (isFallback.value ? placeholder.value.avif : props.card.portraitAvifUrl));
167
167
 
168
168
  const socialItems = computed<ILinkItem[]>(() => (
169
169
  Object.entries(props.card.social || {}).map(([key, value]) => ({
@@ -6,7 +6,14 @@
6
6
 
7
7
  <div class="layout-footer__footer">
8
8
  <LayoutLogo class="layout-footer__logo" />
9
- <LayoutFooterLanguage />
9
+ <div
10
+ v-if="hasNiches"
11
+ class="layout-footer__middle"
12
+ >
13
+ <LayoutNiche class="layout-footer__niche" />
14
+ <LayoutFooterLanguage />
15
+ </div>
16
+ <LayoutFooterLanguage v-else />
10
17
  <p class="layout-footer__copyright">{{ copyrightText }}</p>
11
18
  </div>
12
19
  </footer>
@@ -14,6 +21,7 @@
14
21
 
15
22
  <script setup lang="ts">
16
23
  const copyrightText = useAppConfig().copyrightText;
24
+ const hasNiches = ((useAppConfig().niches as string[] | undefined) || []).length > 1;
17
25
 
18
26
  const { t } = useI18n();
19
27
  </script>
@@ -47,6 +55,27 @@ $breakpoint-footer: sm;
47
55
  }
48
56
  }
49
57
 
58
+ .layout-footer__middle {
59
+ display: flex;
60
+ flex-direction: column;
61
+ row-gap: 16px;
62
+ align-items: center;
63
+
64
+ @include from-br($breakpoint-footer) {
65
+ flex-direction: row;
66
+ column-gap: 24px;
67
+ margin: 0 auto;
68
+ }
69
+ }
70
+
71
+ .layout-footer__niche .layout-niche__list {
72
+ @include from-br($breakpoint-footer) {
73
+ top: -8px;
74
+ bottom: auto;
75
+ transform: translateY(-100%);
76
+ }
77
+ }
78
+
50
79
  .layout-footer__copyright {
51
80
  @include font-xs;
52
81
 
@@ -43,7 +43,7 @@
43
43
  class="layout-header-user__links-wrapper"
44
44
  >
45
45
  <template
46
- v-for="(item, index) in profileNavigationItems.filter(item => item.group === group)"
46
+ v-for="(item, index) in navItems.filter(item => item.group === group)"
47
47
  :key="`profile-link-${index}`"
48
48
  >
49
49
  <component
@@ -136,7 +136,9 @@ function showMenu() {
136
136
  isMenuShow.value = !isMenuShow.value;
137
137
  }
138
138
 
139
- const groupsCount = computed(() => new Set(profileNavigationItems.map(item => item.group)).size);
139
+ const featureFlags = useRuntimeConfig().public.featureFlags as Record<string, boolean | undefined>;
140
+ const navItems = profileNavigationItems.filter(item => !item.featureFlag || featureFlags[item.featureFlag]);
141
+ const groupsCount = computed(() => new Set(navItems.map(item => item.group)).size);
140
142
 
141
143
  function component(hasChild: boolean | undefined) {
142
144
  if (hasChild) {
@@ -15,7 +15,10 @@
15
15
  icon-size="20"
16
16
  />
17
17
 
18
- <LayoutNotifications class="layout-header__notifications"/>
18
+ <LayoutNotifications
19
+ v-if="featureFlags.FeatureFollowEnabled"
20
+ class="layout-header__notifications"
21
+ />
19
22
 
20
23
  <div
21
24
  ref="languageRef"
@@ -91,6 +94,7 @@ import { AuthSteps } from '../../runtime';
91
94
  import { AuthorizationApiService } from '../../services/api/authorization.service';
92
95
 
93
96
  const { locale } = useI18n();
97
+ const featureFlags = useRuntimeConfig().public.featureFlags;
94
98
 
95
99
  const burgerShow = ref(false);
96
100
  const showLanguage = ref(false);
@@ -207,8 +207,11 @@ const emit = defineEmits<{
207
207
 
208
208
  const localeCookie = useCookie('i18n_redirected', { path: '/', secure: true, sameSite: 'lax' as const });
209
209
 
210
+ const { rememberLangChoice } = useLangChoice();
211
+
210
212
  function changeLocale(locale: string) {
211
213
  emit('change', locale);
214
+ rememberLangChoice(locale);
212
215
  localeCookie.value = locale;
213
216
  window.location.href = switchLocalePath(locale);
214
217
  }
@@ -10,6 +10,7 @@
10
10
  />
11
11
  <div class="player-interface__wrapper">
12
12
  <button
13
+ v-if="featureFlags.FeatureFavoritesEnabled"
13
14
  :class="{'--favorite': isFavorite}"
14
15
  :aria-pressed="isFavorite"
15
16
  type="button"
@@ -106,6 +107,7 @@ const props = defineProps<{
106
107
  }>();
107
108
 
108
109
  const { t } = useI18n();
110
+ const featureFlags = useRuntimeConfig().public.featureFlags;
109
111
 
110
112
  const showDescription = ref(false);
111
113
  const descriptionTextRef = ref<HTMLParagraphElement>();
@@ -42,9 +42,10 @@ const props = defineProps<{
42
42
  }>();
43
43
 
44
44
  /** Иконка 32px — квадратный кроп по лицу, если он есть в ответе. */
45
+ const placeholder = useModelPlaceholder()(props.item?.gender, 'square');
45
46
  const icon = computed(() => ({
46
- webp: props.item?.imageUrl || '/img/model-placeholder-square.webp',
47
- avif: props.item?.imageUrl ? props.item.imageAvifUrl : '/img/model-placeholder-square.avif',
47
+ webp: props.item?.imageUrl || placeholder.webp,
48
+ avif: props.item?.imageUrl ? props.item.imageAvifUrl : placeholder.avif,
48
49
  }));
49
50
 
50
51
  const title = computed(() => props.item?.title?.replace(/<[^>]*>/g, ''));
@@ -145,6 +145,7 @@ const topModels = computed(() => {
145
145
  videosCount: item.videosCount,
146
146
  imageUrl: item.primaryImageSmallUrl,
147
147
  imageAvifUrl: item.primaryImageSmallAvifUrl,
148
+ gender: item.gender,
148
149
  })) || [];
149
150
  });
150
151
 
@@ -15,13 +15,17 @@
15
15
 
16
16
  <script setup lang="ts">
17
17
  import { CategoriesApiService } from '../../services/api/categories.service';
18
- import { AsyncData } from '../../runtime';
18
+ import { AsyncData, type Niche } from '../../runtime';
19
19
 
20
- defineProps<{
20
+ const props = defineProps<{
21
21
  footerText?: string
22
22
  footerLinksLink?: string
23
+ /** Ниша страницы — только у сайтов с несколькими нишами: у island свой контекст без стейта ниши. */
24
+ niche?: Niche
23
25
  }>();
24
26
 
27
+ if (props.niche) useState<Niche>('niche').value = props.niche;
28
+
25
29
  const { t } = useI18n();
26
30
 
27
31
  const { data } = await useFetchCategoriesGroupByLetter(
@@ -146,6 +146,8 @@ const props = withDefaults(defineProps<{
146
146
  perPage?: number | string
147
147
  limit?: number
148
148
  arrowIcon?: boolean
149
+ /** путь, на котором живут страницы пагинации; по умолчанию — текущий */
150
+ basePath?: string
149
151
  }>(), {
150
152
  prefix: 'page',
151
153
  perPage: 48,
@@ -181,15 +183,15 @@ watchEffect(() => {
181
183
  });
182
184
 
183
185
  function linkTo(value: string | number) {
184
- return getPaginationLink(value, props.prefix, route.path, route.query);
186
+ return getPaginationLink(value, props.prefix, props.basePath || route.path, route.query);
185
187
  }
186
188
 
187
189
  watch(() => route.query['page'], () => {
188
190
  document.documentElement.querySelector('#anchor')?.scrollIntoView({ behavior: 'instant' });
189
191
  });
190
192
 
191
- // canonical/hreflang выпускает app.vue — отсюда уходит только факт, который знает лишь пагинация
192
- useSetSeoOverride(() => ({ alonePage: props.total === 1 }));
193
+ // canonical/hreflang/prev/next выпускает app.vue — отсюда уходят факты, которые знает лишь пагинация
194
+ useSetSeoOverride(() => ({ alonePage: props.total === 1, totalPages: length.value, paginationPath: props.basePath || undefined }));
193
195
  </script>
194
196
 
195
197
  <style lang="scss">
@@ -90,8 +90,6 @@
90
90
  v-if="similarModels && similarModels.length > 0"
91
91
  :items="modelsRoundCards"
92
92
  :title="$t('similar_models')"
93
- placeholder-avatar="/img/model-placeholder-square.webp"
94
- placeholder-avatar-avif="/img/model-placeholder-square.avif"
95
93
  class="view-model-videos__other-models"
96
94
  hydrate-on-visible
97
95
  />
@@ -6,8 +6,6 @@
6
6
  v-if="topCards.length >= MINIMUM_TOP_THUMBS"
7
7
  :items="topCards"
8
8
  class="view-models__popular"
9
- placeholder-avatar="/img/model-placeholder-square.webp"
10
- placeholder-avatar-avif="/img/model-placeholder-square.avif"
11
9
  only-grid
12
10
  />
13
11
 
@@ -91,8 +91,6 @@
91
91
  :items="modelsRoundCards"
92
92
  :title="$t('related_models')"
93
93
  class="view-video__popular-models"
94
- placeholder-avatar="/img/model-placeholder-square.webp"
95
- placeholder-avatar-avif="/img/model-placeholder-square.avif"
96
94
  hydrate-on-visible
97
95
  />
98
96
 
@@ -205,13 +203,17 @@ const topChips = computed(() => {
205
203
  const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
206
204
 
207
205
  /** Портрет в чипсе — квадратный кроп по лицу; своего у видео нет, его подмешивает BFF. */
206
+ const placeholderOf = useModelPlaceholder();
207
+
208
208
  function getModelChips(model: IVideoData['models'][number]): IChipsItem {
209
+ const placeholder = placeholderOf(model.gender, 'square');
210
+
209
211
  return {
210
212
  prefix: '/models',
211
213
  value: model.title,
212
214
  title: model.title,
213
- image: model.imageUrl || '/img/model-placeholder-square.webp',
214
- imageAvif: model.imageUrl ? model.imageAvifUrl : '/img/model-placeholder-square.avif',
215
+ image: model.imageUrl || placeholder.webp,
216
+ imageAvif: model.imageUrl ? model.imageAvifUrl : placeholder.avif,
215
217
  };
216
218
  }
217
219
  </script>
@@ -49,8 +49,6 @@
49
49
  :all-link-text="$t('show_all_models')"
50
50
  :header-link-text-short="$t('all_models')"
51
51
  class="view-videos__popular-models"
52
- placeholder-avatar="/img/model-placeholder-square.webp"
53
- placeholder-avatar-avif="/img/model-placeholder-square.avif"
54
52
  hydrate-on-visible
55
53
  />
56
54
  </GridVideos>
@@ -75,6 +73,7 @@
75
73
  v-if="!hideCategories && isFirstPage"
76
74
  :footer-text="footerText"
77
75
  :footer-links-link="footerLinksLink"
76
+ v-bind="islandNicheProps"
78
77
  />
79
78
  </div>
80
79
  </template>
@@ -90,6 +89,7 @@ import type {
90
89
  import { convertModelToRoundCard } from '../../runtime';
91
90
 
92
91
  const featureFlags = useRuntimeConfig().public.featureFlags;
92
+ const islandNicheProps = useIslandNicheProps();
93
93
 
94
94
  const props = defineProps<{
95
95
  data: PaginatedResponse<IVideoCard> | undefined
@@ -40,8 +40,8 @@ defineProps<{
40
40
  const { t, te } = useI18n();
41
41
 
42
42
  const webcamsConfig = computed(() => (useRuntimeConfig().public.webcamsConfig || {}) as IWebcamsConfig);
43
- const niche = computed(() => NicheHelper.getSiteDefaultNiche() || 'straight');
44
- const nichePath = computed(() => (niche.value === 'straight' ? '' : `/${niche.value}`));
43
+ const niche = computed(() => NicheHelper.getCurrentNiche() || 'straight');
44
+ const nichePath = computed(() => NicheHelper.getNichePath(niche.value));
45
45
 
46
46
  /**
47
47
  * Заголовок нишевый: у straight — общий, у остальных ниш берём собственный ключ
@@ -46,7 +46,7 @@ const emit = defineEmits<{
46
46
  }>();
47
47
 
48
48
  const webcamsConfig = computed(() => (useRuntimeConfig().public.webcamsConfig || {}) as IWebcamsConfig);
49
- const niche = computed(() => NicheHelper.getSiteDefaultNiche() || 'straight');
49
+ const niche = computed(() => NicheHelper.getCurrentNiche() || 'straight');
50
50
 
51
51
  /** Маппинг полей Stripcash → нормализованная модель. */
52
52
  const normalized = computed<IWebcamModel>(() => {
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment nuxt
2
- import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
3
  import { mockNuxtImport } from '@nuxt/test-utils/runtime';
4
4
  import { useSeoLinks } from '../use-seo-links';
5
5
  import { useSeoOverridesState } from '../use-seo-overrides';
@@ -174,4 +174,65 @@ describe('useSeoLinks', () => {
174
174
  expect(canonicalUrl.value).toContain('categories=teen');
175
175
  expect(canonicalUrl.value).not.toContain('foo');
176
176
  });
177
+
178
+ it('prev/next по totalPages из пагинации', () => {
179
+ mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
180
+ useSeoOverridesState().value = { path: '/videos', totalPages: 3 };
181
+ const { paginationLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
182
+ expect(paginationLinks.value.map(l => [l.rel, l.href])).toEqual([
183
+ ['prev', 'https://example.com/videos?sort=popular'],
184
+ ['next', 'https://example.com/videos?sort=popular&page=3'],
185
+ ]);
186
+ });
187
+
188
+ it('без пагинации prev/next нет', () => {
189
+ const { paginationLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
190
+ expect(paginationLinks.value).toEqual([]);
191
+ });
192
+ });
193
+
194
+ describe('useSeoLinks — локали на поддоменах', () => {
195
+ const locales = useRuntimeConfig().public.locales;
196
+
197
+ beforeEach(() => {
198
+ mockRoute = { path: '/gay/videos', query: { page: '2', sort: 'popular' } };
199
+ mockI18n.locale.value = 'ja';
200
+ useSeoOverridesState().value = null;
201
+ useEnabledLanguageCodes().value = [];
202
+ locales.subdomains = { fr: 'fr', de: 'de', ja: 'jp', zh: 'ch' };
203
+ useRuntimeConfig().public.seo = { canonicalParams: ['page'] };
204
+ });
205
+
206
+ afterEach(() => {
207
+ locales.subdomains = {};
208
+ useRuntimeConfig().public.seo = {};
209
+ });
210
+
211
+ it('canonical на домене текущей локали, путь без префикса', () => {
212
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
213
+ expect(canonicalUrl.value).toBe('https://jp.example.com/gay/videos?page=2');
214
+ });
215
+
216
+ it('hreflang — тот же путь на доменах локалей, x-default на базовом', () => {
217
+ const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
218
+ const byLang = Object.fromEntries(alternateLinks.value.map(l => [l.hreflang, l.href]));
219
+ expect(byLang['zh-CN']).toBe('https://ch.example.com/gay/videos?page=2');
220
+ expect(byLang['en']).toBe('https://example.com/gay/videos?page=2');
221
+ expect(byLang['x-default']).toBe('https://example.com/gay/videos?page=2');
222
+ });
223
+
224
+ it('canonicalLocale уводит на домен указанной локали', () => {
225
+ useSeoOverridesState().value = { path: '/gay/videos', canonicalLocale: 'en' };
226
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
227
+ expect(canonicalUrl.value).toBe('https://example.com/gay/videos?page=2');
228
+ });
229
+
230
+ it('prev/next на домене текущей локали и на пути пагинации', () => {
231
+ useSeoOverridesState().value = { path: '/gay/videos', totalPages: 5, paginationPath: '/gay/videos/popular' };
232
+ const { paginationLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
233
+ expect(paginationLinks.value.map(l => l.href)).toEqual([
234
+ 'https://jp.example.com/gay/videos/popular',
235
+ 'https://jp.example.com/gay/videos/popular?page=3',
236
+ ]);
237
+ });
177
238
  });
@@ -82,9 +82,11 @@ export const useFetchAsideModels = async (
82
82
  }));
83
83
 
84
84
  // Кому роликов хватило — тот в колонке; остальные остаются чипсами над плеером.
85
- watchEffect(() => {
86
- shown.value = models.filter(model => model.videos.value.length).map(model => model.slug);
87
- });
85
+ // Присваиваем сразу, а не эффектом: список читают при рендере (сайт решает по нему,
86
+ // рисовать ли чипсу модели), и реактивная запись пересчитывала бы разметку уже во
87
+ // время гидрации — она расходилась с серверной. Выдача к этому моменту получена и на
88
+ // сервере, и на клиенте (приезжает в payload), так что значение готово.
89
+ shown.value = models.filter(model => model.videos.value.length).map(model => model.slug);
88
90
 
89
91
  return models;
90
92
  };
@@ -8,7 +8,7 @@ import { ModelsApiService } from '../../services/api/models.service';
8
8
  */
9
9
  export const useFetchModelsCount = async () => {
10
10
  const lang = useLang() as Language;
11
- const niche = NicheHelper.getSiteDefaultNiche();
11
+ const niche = NicheHelper.getCurrentNiche();
12
12
 
13
13
  const { data } = await useAsyncData(
14
14
  `models-count-${niche}-${lang}`,
@@ -26,7 +26,7 @@ export const useFetchWebcams = async (options: {
26
26
  const { limit = config?.limit || WEBCAMS_DEFAULTS.limit, category, clientOnly, keepAlive } = options;
27
27
  const isEnabled = !!useRuntimeConfig().public.featureFlags.FeatureWebcamsEnabled;
28
28
  const { locale } = useI18n();
29
- const niche = NicheHelper.getSiteDefaultNiche();
29
+ const niche = NicheHelper.getCurrentNiche();
30
30
 
31
31
  /** Ключ фетча: он же адрес общего списка моделей для проверки живости. */
32
32
  const cacheKey = `webcams-${limit}-${category || 'all'}-${niche}-${locale.value}`;
@@ -1,4 +1,5 @@
1
1
  import type { Language, Niche } from '../runtime';
2
+ import { nicheSlug, type NicheSlugs } from '../config/niches';
2
3
  import { NicheHelper } from '../utils/niche-helper';
3
4
 
4
5
  /** Возвращает generateLink — генерирует путь с учётом текущей ниши и локали. */
@@ -8,23 +9,27 @@ export const useGenerateLink = () => {
8
9
 
9
10
  /**
10
11
  * Генерирует путь с учетом ниши и локали
11
- * @param path - относительный путь (например: '/videos/abc123')
12
+ * @param path - относительный путь (например: '/videos/abc123'); абсолютный URL отдаётся как есть
12
13
  * @param localeArg - локаль
13
14
  * @returns string - корректный путь для <NuxtLink> и navigateTo()
14
15
  */
15
16
  const generateLink = (path: string, localeArg?: Language): string => {
17
+ if (/^https?:\/\//.test(path)) return path;
18
+
16
19
  const cleanPath = path.startsWith('/') ? path : `/${path}`;
17
20
 
18
21
  // Ниша сайта — из окружения (тем же значением уходит в заголовок X-Niche),
19
- // список ниш — из per-site конфига проекта.
22
+ // список ниш и общие для всех ниш разделы — из per-site конфига проекта.
20
23
  const defaultNiche = NicheHelper.getSiteDefaultNiche();
21
- const projectNiches = useAppConfig().niches as Niche[];
22
- const isOneNiche = projectNiches.length === 1;
24
+ const appConfig = useAppConfig();
25
+ const isOneNiche = (appConfig.niches as Niche[]).length === 1;
26
+ const nicheFreeRoots = (appConfig.nicheFreeRoots as string[] | undefined) || [];
27
+ const isNicheFree = nicheFreeRoots.some(root => cleanPath === root || cleanPath.startsWith(`${root}/`));
23
28
 
24
29
  const withNiche =
25
- niche.value === defaultNiche || isOneNiche
30
+ !niche.value || niche.value === defaultNiche || isOneNiche || isNicheFree
26
31
  ? cleanPath
27
- : `/${niche.value}${cleanPath}`;
32
+ : `/${nicheSlug(niche.value, appConfig.nicheSlugs as NicheSlugs | undefined)}${cleanPath}`;
28
33
 
29
34
  const locale = localeArg || undefined;
30
35
  return localePath(withNiche, locale);
@@ -0,0 +1,32 @@
1
+ import { LANG_COOKIE, baseDomainFromHost, hasLocaleSubdomains, type LocaleSubdomains } from '../config/locale-domains';
2
+
3
+ /**
4
+ * Явный выбор языка на сайтах с локалями на поддоменах. Пишет cookie `lang` (значение — поддомен
5
+ * локали, у дефолтной пусто) на `.<базовый домен>`, иначе её не видно с соседних поддоменов. По ней
6
+ * server/middleware/locale-redirect.ts перестаёт уводить по языку браузера. У префиксных локалей —
7
+ * no-op: там выбор помнит cookie `i18n_redirected`.
8
+ */
9
+ export const useLangChoice = () => {
10
+ const subdomains = useRuntimeConfig().public.locales.subdomains as LocaleSubdomains;
11
+
12
+ function rememberLangChoice(code: string) {
13
+ if (!import.meta.client || !hasLocaleSubdomains(subdomains)) return;
14
+
15
+ const base = baseDomainFromHost(window.location.host, subdomains);
16
+ const isLocalhost = /^localhost(?::\d+)?$|^\d+\.\d+\.\d+\.\d+/.test(base);
17
+
18
+ const parts = [
19
+ `${LANG_COOKIE}=${subdomains[code] || ''}`,
20
+ 'path=/',
21
+ `max-age=${60 * 60 * 24 * 365}`,
22
+ 'samesite=lax',
23
+ ];
24
+ // на localhost domain-атрибут с точкой браузеры отбрасывают
25
+ if (!isLocalhost) parts.push(`domain=.${base}`);
26
+ if (window.location.protocol === 'https:') parts.push('secure');
27
+
28
+ document.cookie = parts.join('; ');
29
+ }
30
+
31
+ return { rememberLangChoice };
32
+ };