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.
- package/components/card/channel.vue +6 -3
- package/components/card/model-mini.vue +3 -2
- package/components/card/model.vue +3 -2
- package/components/card/playlist.vue +3 -0
- package/components/card/round.vue +6 -2
- package/components/card/webcam/index.vue +1 -5
- package/components/info/model.vue +4 -4
- package/components/layout/footer.vue +30 -1
- package/components/layout/header-user.vue +4 -2
- package/components/layout/header.vue +5 -1
- package/components/layout/language.vue +3 -0
- package/components/player/interface.vue +2 -0
- package/components/search/item.vue +3 -2
- package/components/search/result.vue +1 -0
- package/components/section/categories-island.server.vue +6 -2
- package/components/ui/pagination.vue +5 -3
- package/components/view/model-videos.vue +0 -2
- package/components/view/models.vue +0 -2
- package/components/view/video.vue +6 -4
- package/components/view/videos.vue +2 -2
- package/components/webcam/header.vue +2 -2
- package/components/webcam/stripcash-card.vue +1 -1
- package/composables/__tests__/use-seo-links.test.ts +62 -1
- package/composables/fetch/use-fetch-aside-models.ts +5 -3
- package/composables/fetch/use-fetch-models-count.ts +1 -1
- package/composables/fetch/use-fetch-webcams.ts +1 -1
- package/composables/use-generate-link.ts +11 -6
- package/composables/use-lang-choice.ts +32 -0
- package/composables/use-meta.ts +42 -2
- package/composables/use-model-placeholder.ts +10 -0
- package/composables/use-niches.ts +9 -1
- package/composables/use-seo-links.ts +71 -17
- package/composables/use-seo-overrides.ts +7 -1
- package/composables/use-webcam-alive.ts +1 -1
- package/config/__tests__/locale-domains.test.ts +53 -0
- package/config/__tests__/niches.test.ts +69 -0
- package/config/index.ts +2 -0
- package/config/locale-domains.ts +95 -0
- package/config/niches.ts +97 -0
- package/lib/scheme/navigation-items.ts +3 -1
- package/lib/scheme/profile-navigation-items.ts +5 -3
- package/middleware/feature-flags.global.ts +13 -0
- package/middleware/niche.global.ts +23 -0
- package/nuxt.config.ts +8 -2
- package/package.json +1 -1
- package/pages/categories/[slug].vue +0 -2
- package/pages/channels/[channel].vue +0 -2
- package/pages/following.vue +3 -2
- package/pages/history.vue +2 -0
- package/pages/library/categories.vue +2 -0
- package/pages/library/channels.vue +2 -0
- package/pages/library/favorites.vue +2 -0
- package/pages/library/index.vue +15 -12
- package/pages/library/models.vue +2 -0
- package/pages/library/playlists.vue +2 -0
- package/pages/library/watch-later.vue +2 -0
- package/pages/search/[slug]/index.vue +1 -5
- package/plugins/i18n-domains.ts +33 -0
- package/public/img/model-placeholder/gay-dark-portrait.avif +0 -0
- package/public/img/model-placeholder/gay-dark-portrait.webp +0 -0
- package/public/img/model-placeholder/gay-dark-square.avif +0 -0
- package/public/img/model-placeholder/gay-dark-square.webp +0 -0
- package/public/img/model-placeholder/gay-light-portrait.avif +0 -0
- package/public/img/model-placeholder/gay-light-portrait.webp +0 -0
- package/public/img/model-placeholder/gay-light-square.avif +0 -0
- package/public/img/model-placeholder/gay-light-square.webp +0 -0
- package/public/img/model-placeholder/shemale-dark-portrait.avif +0 -0
- package/public/img/model-placeholder/shemale-dark-portrait.webp +0 -0
- package/public/img/model-placeholder/shemale-dark-square.avif +0 -0
- package/public/img/model-placeholder/shemale-dark-square.webp +0 -0
- package/public/img/model-placeholder/shemale-light-portrait.avif +0 -0
- package/public/img/model-placeholder/shemale-light-portrait.webp +0 -0
- package/public/img/model-placeholder/shemale-light-square.avif +0 -0
- package/public/img/model-placeholder/shemale-light-square.webp +0 -0
- package/public/img/model-placeholder/straight-dark-portrait.avif +0 -0
- package/public/img/model-placeholder/straight-dark-portrait.webp +0 -0
- package/public/img/model-placeholder/straight-dark-square.avif +0 -0
- package/public/img/model-placeholder/straight-dark-square.webp +0 -0
- package/public/img/model-placeholder/straight-light-portrait.avif +0 -0
- package/public/img/model-placeholder/straight-light-portrait.webp +0 -0
- package/public/img/model-placeholder/straight-light-square.avif +0 -0
- package/public/img/model-placeholder/straight-light-square.webp +0 -0
- package/runtime/index.ts +1 -0
- package/runtime/utils/__tests__/model-placeholder.test.ts +32 -0
- package/runtime/utils/__tests__/video-data-add-model-portraits.test.ts +7 -0
- package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +4 -3
- package/runtime/utils/cleaners/clean-model-card.ts +2 -0
- package/runtime/utils/cleaners/clean-model-info.ts +2 -0
- package/runtime/utils/cleaners/clean-video-data.ts +2 -0
- package/runtime/utils/converters/convert-model-to-round-card.ts +1 -0
- package/runtime/utils/model-placeholder.ts +44 -0
- package/runtime/utils/video-data-add-model-portraits.ts +7 -4
- package/server/middleware/locale-redirect.ts +25 -0
- package/server/utils/__tests__/locale-redirect.test.ts +50 -0
- package/server/utils/locale-redirect.ts +89 -0
- package/services/api/categories.service.ts +10 -10
- package/services/api/channels.service.ts +4 -4
- package/services/api/models.service.ts +12 -12
- package/services/api/playlists.service.ts +5 -5
- package/services/api/search.service.ts +2 -2
- package/services/api/videos.service.ts +24 -24
- package/services/api/webcams.service.ts +1 -1
- package/types/card-info.d.ts +2 -0
- package/types/model-card.d.ts +2 -0
- package/types/raw/raw-model-card.d.ts +2 -0
- package/types/raw/raw-model-info.d.ts +2 -0
- package/types/raw/raw-video-data.d.ts +3 -0
- package/types/round-card.d.ts +2 -0
- package/types/search-top-models.d.ts +2 -0
- package/types/video-data.d.ts +2 -0
- package/utils/__tests__/niche-post-translation.test.ts +13 -0
- package/utils/adverts-helper.ts +3 -3
- package/utils/banner-helper.ts +1 -1
- package/utils/niche-helper.ts +21 -0
- 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
|
-
|
|
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
|
|
56
|
-
const
|
|
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
|
|
102
|
-
const
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
166
|
-
const currentAvifSrc = computed(() => (isFallback.value ?
|
|
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
|
-
<
|
|
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
|
|
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
|
|
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
|
|
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 ||
|
|
47
|
-
avif: props.item?.imageUrl ? props.item.imageAvifUrl :
|
|
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, ''));
|
|
@@ -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
|
/>
|
|
@@ -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 ||
|
|
214
|
-
imageAvif: model.imageUrl ? model.imageAvifUrl :
|
|
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.
|
|
44
|
-
const nichePath = computed(() => (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.
|
|
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
|
-
|
|
86
|
-
|
|
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.
|
|
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.
|
|
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
|
|
22
|
-
const isOneNiche =
|
|
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
|
+
};
|