itube-specs 0.0.997 → 0.0.999
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 +5 -2
- package/components/card/model.vue +5 -3
- package/components/card/playlist.vue +3 -0
- package/components/card/round.vue +8 -2
- package/components/card/webcam/index.vue +1 -1
- package/components/info/model.vue +7 -5
- 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 +5 -3
- 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/video.vue +8 -3
- package/components/view/videos.vue +2 -0
- package/components/webcam/header.vue +1 -1
- 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 +9 -5
- package/composables/use-lang-choice.ts +32 -0
- package/composables/use-meta.ts +42 -2
- package/composables/use-niches.ts +8 -0
- 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 +34 -0
- package/config/index.ts +2 -0
- package/config/locale-domains.ts +95 -0
- package/config/niches.ts +65 -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 +17 -0
- package/nuxt.config.ts +8 -2
- package/package.json +1 -1
- package/pages/following.vue +2 -0
- 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/plugins/i18n-domains.ts +33 -0
- package/public/img/model-placeholder-female-portrait.avif +0 -0
- package/public/img/model-placeholder-female-portrait.webp +0 -0
- package/public/img/model-placeholder-female-square.avif +0 -0
- package/public/img/model-placeholder-female-square.webp +0 -0
- package/public/img/model-placeholder-portrait.avif +0 -0
- package/public/img/model-placeholder-portrait.webp +0 -0
- package/public/img/model-placeholder-square.avif +0 -0
- package/public/img/model-placeholder-square.webp +0 -0
- package/runtime/index.ts +1 -0
- package/runtime/utils/__tests__/model-placeholder.test.ts +25 -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 +30 -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 +8 -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'
|
|
@@ -47,13 +47,16 @@
|
|
|
47
47
|
|
|
48
48
|
<script setup lang="ts">
|
|
49
49
|
import type { IModelCard } from '../../types';
|
|
50
|
+
import { modelPlaceholder } from '../../runtime';
|
|
51
|
+
import { NicheHelper } from '../../utils/niche-helper';
|
|
50
52
|
|
|
51
53
|
const props = defineProps<{
|
|
52
54
|
card: IModelCard
|
|
53
55
|
}>();
|
|
54
56
|
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
+
const placeholder = modelPlaceholder(props.card.gender, NicheHelper.getCurrentNiche(), 'square');
|
|
58
|
+
const fallbackImage = placeholder.webp;
|
|
59
|
+
const fallbackAvifImage = placeholder.avif;
|
|
57
60
|
const imageError = ref(false);
|
|
58
61
|
|
|
59
62
|
/** Аватар 48px круглый — нужен квадратный кроп, вертикальный портрет сюда не годится. */
|
|
@@ -77,7 +77,8 @@
|
|
|
77
77
|
</template>
|
|
78
78
|
|
|
79
79
|
<script setup lang="ts">
|
|
80
|
-
import { convertString, formatDate } from '../../runtime';
|
|
80
|
+
import { convertString, formatDate, modelPlaceholder } from '../../runtime';
|
|
81
|
+
import { NicheHelper } from '../../utils/niche-helper';
|
|
81
82
|
import type { IModelCard } from '../../types';
|
|
82
83
|
|
|
83
84
|
const props = withDefaults(defineProps<{
|
|
@@ -98,8 +99,9 @@ const { t } = useI18n();
|
|
|
98
99
|
*/
|
|
99
100
|
const SIZES = 'auto, (max-width: 449px) 50vw, (max-width: 1385px) 215px, 250px';
|
|
100
101
|
|
|
101
|
-
const
|
|
102
|
-
const
|
|
102
|
+
const placeholder = modelPlaceholder(props.card.gender, NicheHelper.getCurrentNiche(), 'portrait');
|
|
103
|
+
const fallbackImage = placeholder.webp;
|
|
104
|
+
const fallbackAvifImage = placeholder.avif;
|
|
103
105
|
const imageError = ref(false);
|
|
104
106
|
|
|
105
107
|
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();
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
<script setup lang="ts">
|
|
44
44
|
import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
|
|
45
45
|
import type { IRoundCard } from '../../types';
|
|
46
|
+
import { modelPlaceholder } from '../../runtime';
|
|
47
|
+
import { NicheHelper } from '../../utils/niche-helper';
|
|
46
48
|
|
|
47
49
|
const props = withDefaults(defineProps<{
|
|
48
50
|
card: IRoundCard
|
|
@@ -63,7 +65,11 @@ const { generateLink } = useGenerateLink();
|
|
|
63
65
|
/** `--card-round-size`: 80px до sm, 96px дальше. `auto` впереди — см. `UiImg`. */
|
|
64
66
|
const SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
// У карточек моделей есть `gender` (пусть и пустой) — им заглушка по полу и нише, остальным — из пропа.
|
|
69
|
+
const modelFallback = props.card.gender === undefined
|
|
70
|
+
? null
|
|
71
|
+
: modelPlaceholder(props.card.gender, NicheHelper.getCurrentNiche(), 'square');
|
|
72
|
+
const fallbackImage = modelFallback?.webp || props.placeholderAvatar;
|
|
67
73
|
const imageError = ref(false);
|
|
68
74
|
|
|
69
75
|
const isFallback = computed(() => imageError.value || !props.card.primaryImageUrl);
|
|
@@ -73,7 +79,7 @@ const currentImage = computed(() => (
|
|
|
73
79
|
));
|
|
74
80
|
|
|
75
81
|
const currentAvifSrc = computed(() => (
|
|
76
|
-
isFallback.value ? props.placeholderAvatarAvif : props.card.primaryImageAvifUrl
|
|
82
|
+
isFallback.value ? modelFallback?.avif || props.placeholderAvatarAvif : props.card.primaryImageAvifUrl
|
|
77
83
|
));
|
|
78
84
|
|
|
79
85
|
function onImageError() {
|
|
@@ -234,7 +234,7 @@ const ageFilterUrl = computed(() => {
|
|
|
234
234
|
const webcamsPath = useWebcamsPath();
|
|
235
235
|
|
|
236
236
|
const nichePath = computed(() => {
|
|
237
|
-
const niche = NicheHelper.
|
|
237
|
+
const niche = NicheHelper.getCurrentNiche();
|
|
238
238
|
|
|
239
239
|
return niche && niche !== 'straight' ? `/${niche}` : '';
|
|
240
240
|
});
|
|
@@ -136,7 +136,8 @@
|
|
|
136
136
|
</template>
|
|
137
137
|
|
|
138
138
|
<script setup lang="ts">
|
|
139
|
-
import { AuthSteps } from '../../runtime';
|
|
139
|
+
import { AuthSteps, modelPlaceholder } from '../../runtime';
|
|
140
|
+
import { NicheHelper } from '../../utils/niche-helper';
|
|
140
141
|
import type { ICardInfo, IGroupedParameter, ILinkItem, IModelCard } from '../../types';
|
|
141
142
|
import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
|
|
142
143
|
import { AuthorizationApiService } from '../../services/api/authorization.service';
|
|
@@ -151,19 +152,19 @@ const props = defineProps<{
|
|
|
151
152
|
const { t, locale } = useI18n();
|
|
152
153
|
|
|
153
154
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
155
|
+
const niche = NicheHelper.getCurrentNiche();
|
|
154
156
|
|
|
155
157
|
const KNOWN_ICONS = ['facebook', 'instagram', 'tiktok', 'twitter', 'x', 'youtube', 'snapchat'];
|
|
156
158
|
|
|
157
|
-
const
|
|
158
|
-
const PLACEHOLDER_AVIF = '/img/model-placeholder-portrait.avif';
|
|
159
|
+
const placeholder = computed(() => modelPlaceholder(props.card.gender, niche, 'portrait'));
|
|
159
160
|
|
|
160
161
|
/** `.info-model__img` — 160px до sm, 240px дальше. `auto` впереди — см. `UiImg`. */
|
|
161
162
|
const IMG_SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 160px, 240px`;
|
|
162
163
|
|
|
163
164
|
const imageError = ref(false);
|
|
164
165
|
const isFallback = computed(() => imageError.value || !props.card.portraitImg);
|
|
165
|
-
const currentImage = computed(() => (isFallback.value ?
|
|
166
|
-
const currentAvifSrc = computed(() => (isFallback.value ?
|
|
166
|
+
const currentImage = computed(() => (isFallback.value ? placeholder.value.webp : props.card.portraitImg));
|
|
167
|
+
const currentAvifSrc = computed(() => (isFallback.value ? placeholder.value.avif : props.card.portraitAvifUrl));
|
|
167
168
|
|
|
168
169
|
const socialItems = computed<ILinkItem[]>(() => (
|
|
169
170
|
Object.entries(props.card.social || {}).map(([key, value]) => ({
|
|
@@ -276,6 +277,7 @@ const shareModelCard = computed<IModelCard>(() => ({
|
|
|
276
277
|
aspect-ratio: 3/4;
|
|
277
278
|
border-radius: $radius-lg;
|
|
278
279
|
object-fit: cover;
|
|
280
|
+
object-position: center top;
|
|
279
281
|
background-color: $info-model-img-bg;
|
|
280
282
|
}
|
|
281
283
|
|
|
@@ -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>();
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
|
|
32
32
|
<script setup lang="ts">
|
|
33
33
|
import type { ISearchTopModelsType } from '../../types';
|
|
34
|
-
import { convertString } from '../../runtime';
|
|
34
|
+
import { convertString, modelPlaceholder } from '../../runtime';
|
|
35
|
+
import { NicheHelper } from '../../utils/niche-helper';
|
|
35
36
|
import DOMPurify from 'dompurify';
|
|
36
37
|
|
|
37
38
|
const props = defineProps<{
|
|
@@ -42,9 +43,10 @@ const props = defineProps<{
|
|
|
42
43
|
}>();
|
|
43
44
|
|
|
44
45
|
/** Иконка 32px — квадратный кроп по лицу, если он есть в ответе. */
|
|
46
|
+
const placeholder = modelPlaceholder(props.item?.gender, NicheHelper.getCurrentNiche(), 'square');
|
|
45
47
|
const icon = computed(() => ({
|
|
46
|
-
webp: props.item?.imageUrl ||
|
|
47
|
-
avif: props.item?.imageUrl ? props.item.imageAvifUrl :
|
|
48
|
+
webp: props.item?.imageUrl || placeholder.webp,
|
|
49
|
+
avif: props.item?.imageUrl ? props.item.imageAvifUrl : placeholder.avif,
|
|
48
50
|
}));
|
|
49
51
|
|
|
50
52
|
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">
|
|
@@ -112,7 +112,8 @@
|
|
|
112
112
|
|
|
113
113
|
<script setup lang="ts">
|
|
114
114
|
import type { IChipsItem, IModelCard, IStripcashModel, IVideoCard, IVideoData, PaginatedResponse } from '../../types';
|
|
115
|
-
import { convertModelToRoundCard } from '../../runtime';
|
|
115
|
+
import { convertModelToRoundCard, modelPlaceholder } from '../../runtime';
|
|
116
|
+
import { NicheHelper } from '../../utils/niche-helper';
|
|
116
117
|
import type { AsyncDataRequestStatus } from '#app';
|
|
117
118
|
|
|
118
119
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
@@ -205,13 +206,17 @@ const topChips = computed(() => {
|
|
|
205
206
|
const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
|
|
206
207
|
|
|
207
208
|
/** Портрет в чипсе — квадратный кроп по лицу; своего у видео нет, его подмешивает BFF. */
|
|
209
|
+
const niche = NicheHelper.getCurrentNiche();
|
|
210
|
+
|
|
208
211
|
function getModelChips(model: IVideoData['models'][number]): IChipsItem {
|
|
212
|
+
const placeholder = modelPlaceholder(model.gender, niche, 'square');
|
|
213
|
+
|
|
209
214
|
return {
|
|
210
215
|
prefix: '/models',
|
|
211
216
|
value: model.title,
|
|
212
217
|
title: model.title,
|
|
213
|
-
image: model.imageUrl ||
|
|
214
|
-
imageAvif: model.imageUrl ? model.imageAvifUrl :
|
|
218
|
+
image: model.imageUrl || placeholder.webp,
|
|
219
|
+
imageAvif: model.imageUrl ? model.imageAvifUrl : placeholder.avif,
|
|
215
220
|
};
|
|
216
221
|
}
|
|
217
222
|
</script>
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
v-if="!hideCategories && isFirstPage"
|
|
76
76
|
:footer-text="footerText"
|
|
77
77
|
:footer-links-link="footerLinksLink"
|
|
78
|
+
v-bind="islandNicheProps"
|
|
78
79
|
/>
|
|
79
80
|
</div>
|
|
80
81
|
</template>
|
|
@@ -90,6 +91,7 @@ import type {
|
|
|
90
91
|
import { convertModelToRoundCard } from '../../runtime';
|
|
91
92
|
|
|
92
93
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
94
|
+
const islandNicheProps = useIslandNicheProps();
|
|
93
95
|
|
|
94
96
|
const props = defineProps<{
|
|
95
97
|
data: PaginatedResponse<IVideoCard> | undefined
|
|
@@ -40,7 +40,7 @@ defineProps<{
|
|
|
40
40
|
const { t, te } = useI18n();
|
|
41
41
|
|
|
42
42
|
const webcamsConfig = computed(() => (useRuntimeConfig().public.webcamsConfig || {}) as IWebcamsConfig);
|
|
43
|
-
const niche = computed(() => NicheHelper.
|
|
43
|
+
const niche = computed(() => NicheHelper.getCurrentNiche() || 'straight');
|
|
44
44
|
const nichePath = computed(() => (niche.value === 'straight' ? '' : `/${niche.value}`));
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -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}`;
|
|
@@ -8,21 +8,25 @@ export const useGenerateLink = () => {
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Генерирует путь с учетом ниши и локали
|
|
11
|
-
* @param path - относительный путь (например: '/videos/abc123')
|
|
11
|
+
* @param path - относительный путь (например: '/videos/abc123'); абсолютный URL отдаётся как есть
|
|
12
12
|
* @param localeArg - локаль
|
|
13
13
|
* @returns string - корректный путь для <NuxtLink> и navigateTo()
|
|
14
14
|
*/
|
|
15
15
|
const generateLink = (path: string, localeArg?: Language): string => {
|
|
16
|
+
if (/^https?:\/\//.test(path)) return path;
|
|
17
|
+
|
|
16
18
|
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
|
17
19
|
|
|
18
20
|
// Ниша сайта — из окружения (тем же значением уходит в заголовок X-Niche),
|
|
19
|
-
// список ниш — из per-site конфига проекта.
|
|
21
|
+
// список ниш и общие для всех ниш разделы — из per-site конфига проекта.
|
|
20
22
|
const defaultNiche = NicheHelper.getSiteDefaultNiche();
|
|
21
|
-
const
|
|
22
|
-
const isOneNiche =
|
|
23
|
+
const appConfig = useAppConfig();
|
|
24
|
+
const isOneNiche = (appConfig.niches as Niche[]).length === 1;
|
|
25
|
+
const nicheFreeRoots = (appConfig.nicheFreeRoots as string[] | undefined) || [];
|
|
26
|
+
const isNicheFree = nicheFreeRoots.some(root => cleanPath === root || cleanPath.startsWith(`${root}/`));
|
|
23
27
|
|
|
24
28
|
const withNiche =
|
|
25
|
-
niche.value === defaultNiche || isOneNiche
|
|
29
|
+
!niche.value || niche.value === defaultNiche || isOneNiche || isNicheFree
|
|
26
30
|
? cleanPath
|
|
27
31
|
: `/${niche.value}${cleanPath}`;
|
|
28
32
|
|
|
@@ -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
|
+
};
|