itube-specs 0.0.999 → 0.0.1003
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/model-mini.vue +1 -3
- package/components/card/model.vue +2 -3
- package/components/card/round.vue +1 -3
- package/components/card/webcam/index.vue +1 -5
- package/components/info/model.vue +3 -4
- package/components/search/item.vue +2 -3
- package/components/view/model-videos.vue +0 -2
- package/components/view/models.vue +0 -2
- package/components/view/video.vue +3 -6
- package/components/view/videos.vue +0 -2
- package/components/webcam/header.vue +1 -1
- package/composables/use-generate-link.ts +6 -6
- package/composables/use-model-placeholder.ts +10 -0
- package/composables/use-niches.ts +3 -1
- package/config/__tests__/niches.test.ts +61 -4
- package/config/niches.ts +73 -10
- package/middleware/niche.global.ts +12 -6
- package/package.json +1 -1
- package/pages/categories/[slug].vue +0 -2
- package/pages/channels/[channel].vue +0 -2
- package/pages/following.vue +1 -2
- package/pages/search/[slug]/index.vue +1 -5
- 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/utils/__tests__/model-placeholder.test.ts +15 -8
- package/runtime/utils/model-placeholder.ts +21 -7
- package/utils/niche-helper.ts +20 -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
|
@@ -47,14 +47,12 @@
|
|
|
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';
|
|
52
50
|
|
|
53
51
|
const props = defineProps<{
|
|
54
52
|
card: IModelCard
|
|
55
53
|
}>();
|
|
56
54
|
|
|
57
|
-
const placeholder =
|
|
55
|
+
const placeholder = useModelPlaceholder()(props.card.gender, 'square');
|
|
58
56
|
const fallbackImage = placeholder.webp;
|
|
59
57
|
const fallbackAvifImage = placeholder.avif;
|
|
60
58
|
const imageError = ref(false);
|
|
@@ -77,8 +77,7 @@
|
|
|
77
77
|
</template>
|
|
78
78
|
|
|
79
79
|
<script setup lang="ts">
|
|
80
|
-
import { convertString, formatDate
|
|
81
|
-
import { NicheHelper } from '../../utils/niche-helper';
|
|
80
|
+
import { convertString, formatDate } from '../../runtime';
|
|
82
81
|
import type { IModelCard } from '../../types';
|
|
83
82
|
|
|
84
83
|
const props = withDefaults(defineProps<{
|
|
@@ -99,7 +98,7 @@ const { t } = useI18n();
|
|
|
99
98
|
*/
|
|
100
99
|
const SIZES = 'auto, (max-width: 449px) 50vw, (max-width: 1385px) 215px, 250px';
|
|
101
100
|
|
|
102
|
-
const placeholder =
|
|
101
|
+
const placeholder = useModelPlaceholder()(props.card.gender, 'portrait');
|
|
103
102
|
const fallbackImage = placeholder.webp;
|
|
104
103
|
const fallbackAvifImage = placeholder.avif;
|
|
105
104
|
const imageError = ref(false);
|
|
@@ -43,8 +43,6 @@
|
|
|
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';
|
|
48
46
|
|
|
49
47
|
const props = withDefaults(defineProps<{
|
|
50
48
|
card: IRoundCard
|
|
@@ -68,7 +66,7 @@ const SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
|
|
|
68
66
|
// У карточек моделей есть `gender` (пусть и пустой) — им заглушка по полу и нише, остальным — из пропа.
|
|
69
67
|
const modelFallback = props.card.gender === undefined
|
|
70
68
|
? null
|
|
71
|
-
:
|
|
69
|
+
: useModelPlaceholder()(props.card.gender, 'square');
|
|
72
70
|
const fallbackImage = modelFallback?.webp || props.placeholderAvatar;
|
|
73
71
|
const imageError = ref(false);
|
|
74
72
|
|
|
@@ -233,11 +233,7 @@ const ageFilterUrl = computed(() => {
|
|
|
233
233
|
|
|
234
234
|
const webcamsPath = useWebcamsPath();
|
|
235
235
|
|
|
236
|
-
const nichePath = computed(() =>
|
|
237
|
-
const niche = NicheHelper.getCurrentNiche();
|
|
238
|
-
|
|
239
|
-
return niche && niche !== 'straight' ? `/${niche}` : '';
|
|
240
|
-
});
|
|
236
|
+
const nichePath = computed(() => NicheHelper.getNichePath());
|
|
241
237
|
|
|
242
238
|
// Короткая форма («США», а не «Соединенные Штаты») теперь дефолт утилиты — своего
|
|
243
239
|
// вызова ICU карточке больше не надо
|
|
@@ -136,8 +136,7 @@
|
|
|
136
136
|
</template>
|
|
137
137
|
|
|
138
138
|
<script setup lang="ts">
|
|
139
|
-
import { AuthSteps
|
|
140
|
-
import { NicheHelper } from '../../utils/niche-helper';
|
|
139
|
+
import { AuthSteps } from '../../runtime';
|
|
141
140
|
import type { ICardInfo, IGroupedParameter, ILinkItem, IModelCard } from '../../types';
|
|
142
141
|
import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
|
|
143
142
|
import { AuthorizationApiService } from '../../services/api/authorization.service';
|
|
@@ -152,11 +151,11 @@ const props = defineProps<{
|
|
|
152
151
|
const { t, locale } = useI18n();
|
|
153
152
|
|
|
154
153
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
155
|
-
const
|
|
154
|
+
const placeholderOf = useModelPlaceholder();
|
|
156
155
|
|
|
157
156
|
const KNOWN_ICONS = ['facebook', 'instagram', 'tiktok', 'twitter', 'x', 'youtube', 'snapchat'];
|
|
158
157
|
|
|
159
|
-
const placeholder = computed(() =>
|
|
158
|
+
const placeholder = computed(() => placeholderOf(props.card.gender, 'portrait'));
|
|
160
159
|
|
|
161
160
|
/** `.info-model__img` — 160px до sm, 240px дальше. `auto` впереди — см. `UiImg`. */
|
|
162
161
|
const IMG_SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 160px, 240px`;
|
|
@@ -31,8 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
<script setup lang="ts">
|
|
33
33
|
import type { ISearchTopModelsType } from '../../types';
|
|
34
|
-
import { convertString
|
|
35
|
-
import { NicheHelper } from '../../utils/niche-helper';
|
|
34
|
+
import { convertString } from '../../runtime';
|
|
36
35
|
import DOMPurify from 'dompurify';
|
|
37
36
|
|
|
38
37
|
const props = defineProps<{
|
|
@@ -43,7 +42,7 @@ const props = defineProps<{
|
|
|
43
42
|
}>();
|
|
44
43
|
|
|
45
44
|
/** Иконка 32px — квадратный кроп по лицу, если он есть в ответе. */
|
|
46
|
-
const placeholder =
|
|
45
|
+
const placeholder = useModelPlaceholder()(props.item?.gender, 'square');
|
|
47
46
|
const icon = computed(() => ({
|
|
48
47
|
webp: props.item?.imageUrl || placeholder.webp,
|
|
49
48
|
avif: props.item?.imageUrl ? props.item.imageAvifUrl : placeholder.avif,
|
|
@@ -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
|
|
|
@@ -112,8 +110,7 @@
|
|
|
112
110
|
|
|
113
111
|
<script setup lang="ts">
|
|
114
112
|
import type { IChipsItem, IModelCard, IStripcashModel, IVideoCard, IVideoData, PaginatedResponse } from '../../types';
|
|
115
|
-
import { convertModelToRoundCard
|
|
116
|
-
import { NicheHelper } from '../../utils/niche-helper';
|
|
113
|
+
import { convertModelToRoundCard } from '../../runtime';
|
|
117
114
|
import type { AsyncDataRequestStatus } from '#app';
|
|
118
115
|
|
|
119
116
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
@@ -206,10 +203,10 @@ const topChips = computed(() => {
|
|
|
206
203
|
const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
|
|
207
204
|
|
|
208
205
|
/** Портрет в чипсе — квадратный кроп по лицу; своего у видео нет, его подмешивает BFF. */
|
|
209
|
-
const
|
|
206
|
+
const placeholderOf = useModelPlaceholder();
|
|
210
207
|
|
|
211
208
|
function getModelChips(model: IVideoData['models'][number]): IChipsItem {
|
|
212
|
-
const placeholder =
|
|
209
|
+
const placeholder = placeholderOf(model.gender, 'square');
|
|
213
210
|
|
|
214
211
|
return {
|
|
215
212
|
prefix: '/models',
|
|
@@ -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>
|
|
@@ -41,7 +41,7 @@ const { t, te } = useI18n();
|
|
|
41
41
|
|
|
42
42
|
const webcamsConfig = computed(() => (useRuntimeConfig().public.webcamsConfig || {}) as IWebcamsConfig);
|
|
43
43
|
const niche = computed(() => NicheHelper.getCurrentNiche() || 'straight');
|
|
44
|
-
const nichePath = computed(() => (niche.value
|
|
44
|
+
const nichePath = computed(() => NicheHelper.getNichePath(niche.value));
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* Заголовок нишевый: у straight — общий, у остальных ниш берём собственный ключ
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Language, Niche } from '../runtime';
|
|
2
|
+
import { nicheSlug, withNichePrefix, type NicheSlugs } from '../config/niches';
|
|
2
3
|
import { NicheHelper } from '../utils/niche-helper';
|
|
3
4
|
|
|
4
5
|
/** Возвращает generateLink — генерирует путь с учётом текущей ниши и локали. */
|
|
@@ -25,13 +26,12 @@ export const useGenerateLink = () => {
|
|
|
25
26
|
const nicheFreeRoots = (appConfig.nicheFreeRoots as string[] | undefined) || [];
|
|
26
27
|
const isNicheFree = nicheFreeRoots.some(root => cleanPath === root || cleanPath.startsWith(`${root}/`));
|
|
27
28
|
|
|
28
|
-
const withNiche =
|
|
29
|
-
!niche.value || niche.value === defaultNiche || isOneNiche || isNicheFree
|
|
30
|
-
? cleanPath
|
|
31
|
-
: `/${niche.value}${cleanPath}`;
|
|
32
|
-
|
|
33
29
|
const locale = localeArg || undefined;
|
|
34
|
-
|
|
30
|
+
const localized = localePath(cleanPath, locale);
|
|
31
|
+
|
|
32
|
+
if (!niche.value || niche.value === defaultNiche || isOneNiche || isNicheFree) return localized;
|
|
33
|
+
|
|
34
|
+
return withNichePrefix(localized, `/${nicheSlug(niche.value, appConfig.nicheSlugs as NicheSlugs | undefined)}`, localePath('/', locale));
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
return { generateLink };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { modelPlaceholder, type ModelPlaceholderShape } from '../runtime';
|
|
2
|
+
import { NicheHelper } from '../utils/niche-helper';
|
|
3
|
+
|
|
4
|
+
/** Заглушка портрета модели под нишу страницы и тему сайта (`appConfig.theme`); пол модели — аргументом. */
|
|
5
|
+
export const useModelPlaceholder = () => {
|
|
6
|
+
const niche = NicheHelper.getCurrentNiche();
|
|
7
|
+
const theme = useAppConfig().theme as string | undefined;
|
|
8
|
+
|
|
9
|
+
return (gender: string | undefined, shape: ModelPlaceholderShape) => modelPlaceholder(gender, niche, theme, shape);
|
|
10
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Niche } from '../runtime';
|
|
2
|
+
import { withNichePrefix } from '../config/niches';
|
|
2
3
|
import { NicheHelper } from '../utils/niche-helper';
|
|
3
4
|
|
|
4
5
|
/** Иконка из assets/icons/common под каждую нишу. */
|
|
@@ -40,7 +41,8 @@ export const useNiches = () => {
|
|
|
40
41
|
function changeNiche(name: Niche) {
|
|
41
42
|
if (name === current.value) return;
|
|
42
43
|
current.value = name;
|
|
43
|
-
|
|
44
|
+
const root = localePath('/');
|
|
45
|
+
window.location.href = withNichePrefix(root, NicheHelper.getNichePath(name), root);
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
return { niches, currentNiche, changeNiche };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { addNicheAliases, nicheRoutePrefix } from '../niches';
|
|
2
|
+
import { addNicheAliases, nicheFromPath, nicheFromSlug, nicheRoutePrefix, nicheSlug, nichePath, withNichePrefix } from '../niches';
|
|
3
3
|
|
|
4
4
|
const CONFIG = { niches: ['straight', 'gay', 'shemale'], defaultNiche: 'straight', contentRoots: ['/', '/videos', '/models'] };
|
|
5
5
|
|
|
@@ -19,13 +19,70 @@ describe('niches', () => {
|
|
|
19
19
|
];
|
|
20
20
|
addNicheAliases(pages, CONFIG);
|
|
21
21
|
|
|
22
|
-
expect(pages[0]?.alias).toEqual(['
|
|
23
|
-
expect(pages[1]?.alias).toEqual(['/v/:slug', '/:
|
|
24
|
-
expect(pages[2]?.alias).toEqual(['
|
|
22
|
+
expect(pages[0]?.alias).toEqual(['/gay', '/shemale']);
|
|
23
|
+
expect(pages[1]?.alias).toEqual(['/v/:slug', '/gay/videos/:slug', '/shemale/videos/:slug']);
|
|
24
|
+
expect(pages[2]?.alias).toEqual(['/gay/models', '/shemale/models']);
|
|
25
25
|
expect(pages[3]?.alias).toBeUndefined();
|
|
26
26
|
expect(pages[4]?.alias).toBeUndefined();
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
it('слаги ниш: в префиксе роута слаг, ниша без слага — под кодом', () => {
|
|
30
|
+
const slugs = { gay: 'gays' };
|
|
31
|
+
expect(nicheRoutePrefix({ ...CONFIG, slugs })).toBe('/:niche(gays|shemale)');
|
|
32
|
+
|
|
33
|
+
const pages = [{ path: '/videos/:slug' }];
|
|
34
|
+
addNicheAliases(pages, { ...CONFIG, slugs });
|
|
35
|
+
expect(pages[0]?.alias).toEqual(['/gays/videos/:slug', '/shemale/videos/:slug']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('префикс пути ниши: слаг, у дефолтной и одной ниши пусто', () => {
|
|
39
|
+
const slugs = { gay: 'gays' };
|
|
40
|
+
expect(nichePath('gay', { ...CONFIG, slugs })).toBe('/gays');
|
|
41
|
+
expect(nichePath('shemale', { ...CONFIG, slugs })).toBe('/shemale');
|
|
42
|
+
expect(nichePath('straight', CONFIG)).toBe('');
|
|
43
|
+
expect(nichePath(undefined, CONFIG)).toBe('');
|
|
44
|
+
expect(nichePath('gay', { niches: ['gay'], defaultNiche: 'straight' })).toBe('');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('префикс ниши в локализованном пути — после языка', () => {
|
|
48
|
+
expect(withNichePrefix('/models', '/gays')).toBe('/gays/models');
|
|
49
|
+
expect(withNichePrefix('/', '/gays')).toBe('/gays');
|
|
50
|
+
expect(withNichePrefix('/?page=2', '/gays')).toBe('/gays?page=2');
|
|
51
|
+
expect(withNichePrefix('/ru/models', '/gays', '/ru')).toBe('/ru/gays/models');
|
|
52
|
+
expect(withNichePrefix('/ru', '/gays', '/ru')).toBe('/ru/gays');
|
|
53
|
+
expect(withNichePrefix('/ru?page=2', '/gays', '/ru')).toBe('/ru/gays?page=2');
|
|
54
|
+
expect(withNichePrefix('/rubber', '/gays', '/ru')).toBe('/gays/rubber');
|
|
55
|
+
expect(withNichePrefix('/models', '')).toBe('/models');
|
|
56
|
+
expect(withNichePrefix('https://ru.example.com/models', '/gays')).toBe('https://ru.example.com/gays/models');
|
|
57
|
+
expect(withNichePrefix('https://ru.example.com', '/gays')).toBe('https://ru.example.com/gays');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('слаг ↔ ниша', () => {
|
|
61
|
+
const slugs = { gay: 'gays', shemale: 'shemales' };
|
|
62
|
+
expect(nicheSlug('gay', slugs)).toBe('gays');
|
|
63
|
+
expect(nicheSlug('straight', slugs)).toBe('straight');
|
|
64
|
+
expect(nicheSlug('gay')).toBe('gay');
|
|
65
|
+
expect(nicheFromSlug('gays', CONFIG.niches, slugs)).toBe('gay');
|
|
66
|
+
expect(nicheFromSlug('gay', CONFIG.niches, slugs)).toBeUndefined();
|
|
67
|
+
expect(nicheFromSlug('gay', CONFIG.niches)).toBe('gay');
|
|
68
|
+
expect(nicheFromSlug('videos', CONFIG.niches, slugs)).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('ниша по пути: с языковым префиксом и без, со слагами', () => {
|
|
72
|
+
const locales = ['en', 'ru', 'ja'];
|
|
73
|
+
const slugs = { gay: 'gays', shemale: 'shemales' };
|
|
74
|
+
|
|
75
|
+
expect(nicheFromPath('/gay/videos', CONFIG, locales)).toBe('gay');
|
|
76
|
+
expect(nicheFromPath('/ru/gay/videos', CONFIG, locales)).toBe('gay');
|
|
77
|
+
expect(nicheFromPath('/ru/gays/videos', { ...CONFIG, slugs }, locales)).toBe('gay');
|
|
78
|
+
expect(nicheFromPath('/shemales', { ...CONFIG, slugs }, locales)).toBe('shemale');
|
|
79
|
+
expect(nicheFromPath('/ru', CONFIG, locales)).toBe('straight');
|
|
80
|
+
expect(nicheFromPath('/ru/videos', CONFIG, locales)).toBe('straight');
|
|
81
|
+
expect(nicheFromPath('/', CONFIG, locales)).toBe('straight');
|
|
82
|
+
expect(nicheFromPath('/straight/videos', CONFIG, locales)).toBe('straight');
|
|
83
|
+
expect(nicheFromPath('/ru/gay', CONFIG)).toBe('straight');
|
|
84
|
+
});
|
|
85
|
+
|
|
29
86
|
it('сайт с одной нишей: роуты без изменений', () => {
|
|
30
87
|
const pages = [{ path: '/videos' }];
|
|
31
88
|
addNicheAliases(pages, { niches: ['gay'], defaultNiche: 'gay', contentRoots: ['/videos'] });
|
package/config/niches.ts
CHANGED
|
@@ -17,6 +17,21 @@ export interface INicheRouting {
|
|
|
17
17
|
* они одни на весь сайт.
|
|
18
18
|
*/
|
|
19
19
|
contentRoots: readonly string[]
|
|
20
|
+
/** Слаг ниши в URL, если он не совпадает с её кодом (`{ gay: 'gays' }`). */
|
|
21
|
+
slugs?: NicheSlugs
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Карта «код ниши → слаг в URL». Ниши без записи живут в URL под своим кодом. */
|
|
25
|
+
export type NicheSlugs = Readonly<Record<string, string>>;
|
|
26
|
+
|
|
27
|
+
/** Слаг ниши в URL: из карты сайта, без неё — код ниши. */
|
|
28
|
+
export function nicheSlug(niche: string, slugs?: NicheSlugs | null): string {
|
|
29
|
+
return slugs?.[niche] || niche;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Ниша по сегменту URL — обратное к `nicheSlug`. Не ниша — `undefined`. */
|
|
33
|
+
export function nicheFromSlug(slug: string, niches: readonly string[], slugs?: NicheSlugs | null): string | undefined {
|
|
34
|
+
return niches.find(niche => nicheSlug(niche, slugs) === slug);
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
/** То, что нужно от записи роута: путь и его алиасы (`NuxtPage` подходит как есть). */
|
|
@@ -25,17 +40,34 @@ export interface INicheAliasablePage {
|
|
|
25
40
|
alias?: string | string[]
|
|
26
41
|
}
|
|
27
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Ниша страницы по пути: первый сегмент после языкового префикса (`/ru/gays/videos` → `gay`), не ниша или
|
|
45
|
+
* дефолтная — дефолтная. Языковой сегмент пропускается по кодам локалей сайта (`prefix_except_default`);
|
|
46
|
+
* на локалях-поддоменах префикса нет, и пропускать нечего.
|
|
47
|
+
*/
|
|
48
|
+
export function nicheFromPath(
|
|
49
|
+
path: string,
|
|
50
|
+
{ niches, defaultNiche, slugs }: Pick<INicheRouting, 'niches' | 'defaultNiche' | 'slugs'>,
|
|
51
|
+
localeCodes: readonly string[] = [],
|
|
52
|
+
): string {
|
|
53
|
+
const segments = path.split('/').filter(Boolean);
|
|
54
|
+
const segment = (segments[0] && localeCodes.includes(segments[0]) ? segments[1] : segments[0]) || '';
|
|
55
|
+
const niche = nicheFromSlug(segment, niches, slugs);
|
|
56
|
+
|
|
57
|
+
return niche && niche !== defaultNiche ? niche : defaultNiche;
|
|
58
|
+
}
|
|
59
|
+
|
|
28
60
|
/** Ниши, которые живут под префиксом (все, кроме дефолтной). */
|
|
29
61
|
export function prefixedNiches({ niches, defaultNiche }: Pick<INicheRouting, 'niches' | 'defaultNiche'>): string[] {
|
|
30
62
|
return niches.filter(niche => niche !== defaultNiche);
|
|
31
63
|
}
|
|
32
64
|
|
|
33
65
|
/**
|
|
34
|
-
* Префикс пути для нишевых алиасов: `/:niche(gay|shemale)
|
|
35
|
-
* Сайт берёт его, если строит собственные нишевые роуты рядом с алиасами.
|
|
66
|
+
* Префикс пути для нишевых алиасов: `/:niche(gay|shemale)`, со слагами — `/:niche(gays|shemales)`.
|
|
67
|
+
* У сайта с одной нишей — пусто. Сайт берёт его, если строит собственные нишевые роуты рядом с алиасами.
|
|
36
68
|
*/
|
|
37
|
-
export function nicheRoutePrefix(config: Pick<INicheRouting, 'niches' | 'defaultNiche'>): string {
|
|
38
|
-
const prefixed = prefixedNiches(config);
|
|
69
|
+
export function nicheRoutePrefix(config: Pick<INicheRouting, 'niches' | 'defaultNiche' | 'slugs'>): string {
|
|
70
|
+
const prefixed = prefixedNiches(config).map(niche => nicheSlug(niche, config.slugs));
|
|
39
71
|
|
|
40
72
|
return prefixed.length ? `/:niche(${prefixed.join('|')})` : '';
|
|
41
73
|
}
|
|
@@ -47,19 +79,50 @@ function isUnderRoot(path: string, roots: readonly string[]): boolean {
|
|
|
47
79
|
|
|
48
80
|
/**
|
|
49
81
|
* Клонирует контентные разделы под нишевые префиксы — алиасом той же записи роута:
|
|
50
|
-
* `/videos/:slug` начинает отвечать и на `/gay/videos/:slug`.
|
|
51
|
-
*
|
|
82
|
+
* `/videos/:slug` начинает отвечать и на `/gay/videos/:slug`. Алиасы статичные, по одному на нишу:
|
|
83
|
+
* у алиаса vue-router требует те же параметры, что у оригинала, поэтому параметр `:niche` сюда нельзя.
|
|
84
|
+
* Сайту с одной нишей ничего не добавляет. Мутирует переданный список — так устроен хук `pages:extend`.
|
|
52
85
|
*/
|
|
53
86
|
export function addNicheAliases(pages: INicheAliasablePage[], config: INicheRouting): void {
|
|
54
|
-
const
|
|
55
|
-
if (!
|
|
87
|
+
const prefixes = prefixedNiches(config).map(niche => `/${nicheSlug(niche, config.slugs)}`);
|
|
88
|
+
if (!prefixes.length) return;
|
|
56
89
|
|
|
57
90
|
for (const page of pages) {
|
|
58
91
|
if (!page.path || !isUnderRoot(page.path, config.contentRoots)) continue;
|
|
59
92
|
|
|
60
|
-
const
|
|
93
|
+
const aliases = prefixes.map(prefix => (page.path === '/' ? prefix : `${prefix}${page.path}`));
|
|
61
94
|
const current = Array.isArray(page.alias) ? page.alias : page.alias ? [page.alias] : [];
|
|
62
95
|
|
|
63
|
-
page.alias = [...current,
|
|
96
|
+
page.alias = [...current, ...aliases];
|
|
64
97
|
}
|
|
65
98
|
}
|
|
99
|
+
|
|
100
|
+
/** Префикс пути ниши (`/gays`). У дефолтной ниши и у сайта с одной нишей — пусто. */
|
|
101
|
+
export function nichePath(niche: string | undefined, { niches, defaultNiche, slugs }: Pick<INicheRouting, 'niches' | 'defaultNiche' | 'slugs'>): string {
|
|
102
|
+
if (!niche || niches.length <= 1 || niche === defaultNiche) return '';
|
|
103
|
+
|
|
104
|
+
return `/${nicheSlug(niche, slugs)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ORIGIN_RE = /^https?:\/\/[^/?#]+/;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Вставляет префикс ниши в уже локализованный путь — после языкового префикса: `/ru/models` → `/ru/gays/models`.
|
|
111
|
+
* Префикс ниши добавляется после `localePath`, а не до: на нишевом пути i18n находит роут по алиасу и
|
|
112
|
+
* пересобирает путь по имени роута — то есть без ниши (так же `switchLocalePath`). `localeRoot` —
|
|
113
|
+
* `localePath('/')` той же локали. Абсолютный URL (локали на поддоменах) — префикс в его путь.
|
|
114
|
+
*/
|
|
115
|
+
export function withNichePrefix(localizedPath: string, nichePath: string, localeRoot = '/'): string {
|
|
116
|
+
if (!nichePath) return localizedPath;
|
|
117
|
+
|
|
118
|
+
const origin = localizedPath.match(ORIGIN_RE)?.[0] || '';
|
|
119
|
+
if (origin) return `${origin}${withNichePrefix(localizedPath.slice(origin.length) || '/', nichePath, localeRoot)}`;
|
|
120
|
+
|
|
121
|
+
const localeRootPath = localeRoot.replace(ORIGIN_RE, '').replace(/\/$/, '');
|
|
122
|
+
const afterRoot = localizedPath.slice(localeRootPath.length);
|
|
123
|
+
const root = localeRootPath && localizedPath.startsWith(localeRootPath) && (!afterRoot || /^[/?#]/.test(afterRoot)) ? localeRootPath : '';
|
|
124
|
+
const rest = localizedPath.slice(root.length);
|
|
125
|
+
const tail = rest === '/' ? '' : rest.startsWith('/?') || rest.startsWith('/#') ? rest.slice(1) : rest;
|
|
126
|
+
|
|
127
|
+
return `${root}${nichePath}${tail}`;
|
|
128
|
+
}
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import type { Niche } from '../runtime';
|
|
2
|
+
import { nicheFromPath, type NicheSlugs } from '../config/niches';
|
|
2
3
|
import { NicheHelper } from '../utils/niche-helper';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* Ниша страницы из префикса пути (`/gay/...` → `gay`,
|
|
6
|
-
*
|
|
6
|
+
* Ниша страницы из префикса пути (`/gay/...` → `gay`, со слагами `/gays/...` → `gay`, с языком
|
|
7
|
+
* `/ru/gays/...` → `gay`, без префикса — дефолтная). Работает только у сайтов с несколькими нишами
|
|
8
|
+
* (`appConfig.niches`); у сайта с одной нишей — no-op.
|
|
7
9
|
*/
|
|
8
10
|
export default defineNuxtRouteMiddleware((to) => {
|
|
9
|
-
const
|
|
11
|
+
const appConfig = useAppConfig();
|
|
12
|
+
const niches = (appConfig.niches as Niche[] | undefined) || [];
|
|
10
13
|
if (niches.length <= 1) return;
|
|
11
14
|
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
+
const localeCodes = (useRuntimeConfig().public.locales?.codes as string[] | undefined) || [];
|
|
16
|
+
const niche = nicheFromPath(to.path, {
|
|
17
|
+
niches,
|
|
18
|
+
defaultNiche: NicheHelper.getSiteDefaultNiche(),
|
|
19
|
+
slugs: appConfig.nicheSlugs as NicheSlugs | undefined,
|
|
20
|
+
}, localeCodes) as Niche;
|
|
15
21
|
|
|
16
22
|
useState<Niche>('niche', () => niche).value = niche;
|
|
17
23
|
});
|
package/package.json
CHANGED
|
@@ -55,8 +55,6 @@
|
|
|
55
55
|
:items="modelsRoundCards"
|
|
56
56
|
:title="$t('category_top_models_title', {name: title})"
|
|
57
57
|
class="category__models"
|
|
58
|
-
placeholder-avatar="/img/model-placeholder-square.webp"
|
|
59
|
-
placeholder-avatar-avif="/img/model-placeholder-square.avif"
|
|
60
58
|
/>
|
|
61
59
|
</template>
|
|
62
60
|
</GridVideos>
|
|
@@ -52,8 +52,6 @@
|
|
|
52
52
|
:items="modelsRoundCards"
|
|
53
53
|
:title="t('channels_popular_models')"
|
|
54
54
|
class="channel__videos-models"
|
|
55
|
-
placeholder-avatar="/img/model-placeholder-square.webp"
|
|
56
|
-
placeholder-avatar-avif="/img/model-placeholder-square.avif"
|
|
57
55
|
hydrate-on-visible
|
|
58
56
|
/>
|
|
59
57
|
</template>
|
package/pages/following.vue
CHANGED
|
@@ -18,8 +18,6 @@
|
|
|
18
18
|
all-link="/library/models"
|
|
19
19
|
icon="user"
|
|
20
20
|
variant="section"
|
|
21
|
-
placeholder-avatar="/img/model-placeholder-square.webp"
|
|
22
|
-
placeholder-avatar-avif="/img/model-placeholder-square.avif"
|
|
23
21
|
class="following__section"
|
|
24
22
|
without-count
|
|
25
23
|
/>
|
|
@@ -136,6 +134,7 @@ const playlists = computed(() => byNewCount(getLibraryPlaylistsStub()));
|
|
|
136
134
|
const models = computed<IRoundCard[]>(() => modelsStub.value.map(model => ({
|
|
137
135
|
title: model.title,
|
|
138
136
|
primaryImageUrl: model.avatarUrl,
|
|
137
|
+
gender: '',
|
|
139
138
|
videosCount: model.videosCount,
|
|
140
139
|
link: `/models/${convertString().toSlug(model.title)}`,
|
|
141
140
|
newCount: model.newCount,
|
|
@@ -47,11 +47,7 @@
|
|
|
47
47
|
:slide-width="96"
|
|
48
48
|
>
|
|
49
49
|
<template #default="item">
|
|
50
|
-
<CardRound
|
|
51
|
-
:card="item"
|
|
52
|
-
placeholder-avatar="/img/model-placeholder-square.webp"
|
|
53
|
-
placeholder-avatar-avif="/img/model-placeholder-square.avif"
|
|
54
|
-
/>
|
|
50
|
+
<CardRound :card="item"/>
|
|
55
51
|
</template>
|
|
56
52
|
<template
|
|
57
53
|
v-if="modelsData.total > MAXIMUM_MODELS"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -2,16 +2,23 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { modelGender, modelPlaceholder } from '../model-placeholder';
|
|
3
3
|
|
|
4
4
|
describe('modelPlaceholder', () => {
|
|
5
|
-
it('по
|
|
6
|
-
expect(modelPlaceholder('male', 'straight', 'portrait').webp).toBe('/img/model-placeholder-portrait.webp');
|
|
7
|
-
expect(modelPlaceholder('female', 'gay', 'square').avif).toBe('/img/model-placeholder-
|
|
8
|
-
expect(modelPlaceholder('transgender', '
|
|
5
|
+
it('набор по полу: мужчине гей, транс — шимейл, девушке стрейт', () => {
|
|
6
|
+
expect(modelPlaceholder('male', 'straight', 'dark', 'portrait').webp).toBe('/img/model-placeholder/gay-dark-portrait.webp');
|
|
7
|
+
expect(modelPlaceholder('female', 'gay', 'light', 'square').avif).toBe('/img/model-placeholder/straight-light-square.avif');
|
|
8
|
+
expect(modelPlaceholder('transgender', 'straight', 'dark', 'square').webp).toBe('/img/model-placeholder/shemale-dark-square.webp');
|
|
9
|
+
expect(modelPlaceholder('trans woman', 'gay', 'dark', 'square').webp).toBe('/img/model-placeholder/shemale-dark-square.webp');
|
|
9
10
|
});
|
|
10
11
|
|
|
11
|
-
it('пол неизвестен — по
|
|
12
|
-
expect(modelPlaceholder('', 'gay', 'square').webp).toBe('/img/model-placeholder-square.webp');
|
|
13
|
-
expect(modelPlaceholder(undefined, '
|
|
14
|
-
expect(modelPlaceholder('', '
|
|
12
|
+
it('пол неизвестен — по нише, неизвестная ниша — стрейт', () => {
|
|
13
|
+
expect(modelPlaceholder('', 'gay', 'dark', 'square').webp).toBe('/img/model-placeholder/gay-dark-square.webp');
|
|
14
|
+
expect(modelPlaceholder(undefined, 'shemale', 'light', 'portrait').webp).toBe('/img/model-placeholder/shemale-light-portrait.webp');
|
|
15
|
+
expect(modelPlaceholder('', undefined, 'dark', 'portrait').webp).toBe('/img/model-placeholder/straight-dark-portrait.webp');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('тема: dark/light, неизвестная — dark', () => {
|
|
19
|
+
expect(modelPlaceholder('', 'gay', 'light', 'square').webp).toBe('/img/model-placeholder/gay-light-square.webp');
|
|
20
|
+
expect(modelPlaceholder('', 'gay', 'black', 'square').webp).toBe('/img/model-placeholder/gay-dark-square.webp');
|
|
21
|
+
expect(modelPlaceholder('', 'gay', undefined, 'square').webp).toBe('/img/model-placeholder/gay-dark-square.webp');
|
|
15
22
|
});
|
|
16
23
|
});
|
|
17
24
|
|
|
@@ -7,6 +7,9 @@ type GenderSource = {
|
|
|
7
7
|
parameters?: Array<{ name: string, values?: Array<{ name: string }> }> | null
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
const PLACEHOLDER_NICHES = ['straight', 'gay', 'shemale'];
|
|
11
|
+
const PLACEHOLDER_THEMES = ['dark', 'light'];
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* Пол сырой модели (`female`, `male`, `transgender`, `trans woman`…): поле `gender` (v1-ручки) или
|
|
12
15
|
* параметр `gender` (v2). Нет ни того, ни другого — пусто.
|
|
@@ -15,16 +18,27 @@ export const modelGender = (model?: GenderSource | null): string => model?.gende
|
|
|
15
18
|
|| (Array.isArray(model?.parameters) && model.parameters.find(item => item.name === 'gender')?.values?.[0]?.name)
|
|
16
19
|
|| '';
|
|
17
20
|
|
|
21
|
+
/** Набор заглушки по полу: мужчинам — гей, транс — шимейл, девушкам — стрейт; пол неизвестен — ниша страницы. */
|
|
22
|
+
const placeholderNiche = (gender: string | undefined, niche: string | undefined): string => {
|
|
23
|
+
const value = (gender || '').toLowerCase();
|
|
24
|
+
|
|
25
|
+
if (value === 'male') return 'gay';
|
|
26
|
+
if (/trans|shemale/.test(value)) return 'shemale';
|
|
27
|
+
if (value === 'female') return 'straight';
|
|
28
|
+
|
|
29
|
+
return niche && PLACEHOLDER_NICHES.includes(niche) ? niche : 'straight';
|
|
30
|
+
};
|
|
31
|
+
|
|
18
32
|
/**
|
|
19
|
-
* Заглушка вместо портрета
|
|
20
|
-
*
|
|
33
|
+
* Заглушка вместо портрета модели: набор (стрейт/гей/шимейл) × тема (dark/light) × кроп. Набор — по полу,
|
|
34
|
+
* пол неизвестен — по нише; неизвестная тема — dark.
|
|
21
35
|
*
|
|
22
|
-
* Файлы лежат в `public/img
|
|
23
|
-
*
|
|
36
|
+
* Файлы лежат в слое: `public/img/model-placeholder/<niche>-<theme>-<shape>.{webp,avif}`. Сайт перекрывает
|
|
37
|
+
* любой своим файлом с тем же путём.
|
|
24
38
|
*/
|
|
25
|
-
export const modelPlaceholder = (gender: string | undefined, niche: string | undefined, shape: ModelPlaceholderShape) => {
|
|
26
|
-
const
|
|
27
|
-
const base = `/img/model-placeholder
|
|
39
|
+
export const modelPlaceholder = (gender: string | undefined, niche: string | undefined, theme: string | undefined, shape: ModelPlaceholderShape) => {
|
|
40
|
+
const tone = theme && PLACEHOLDER_THEMES.includes(theme) ? theme : 'dark';
|
|
41
|
+
const base = `/img/model-placeholder/${placeholderNiche(gender, niche)}-${tone}-${shape}`;
|
|
28
42
|
|
|
29
43
|
return { webp: `${base}.webp`, avif: `${base}.avif` };
|
|
30
44
|
};
|
package/utils/niche-helper.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Niche } from '../runtime';
|
|
2
|
+
import { nichePath, type NicheSlugs } from '../config/niches';
|
|
2
3
|
|
|
3
4
|
/** Хелпер для работы с нишами */
|
|
4
5
|
export class NicheHelper {
|
|
@@ -14,4 +15,23 @@ export class NicheHelper {
|
|
|
14
15
|
public static getCurrentNiche(): Niche {
|
|
15
16
|
return (useState<Niche>('niche').value || NicheHelper.getSiteDefaultNiche()) as Niche;
|
|
16
17
|
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Префикс пути ниши (`/gay`, со слагами из `appConfig.nicheSlugs` — `/gays`). У дефолтной ниши и у
|
|
21
|
+
* сайта с одной нишей — пусто.
|
|
22
|
+
*/
|
|
23
|
+
public static getNichePath(niche: Niche = NicheHelper.getCurrentNiche()): string {
|
|
24
|
+
return nichePath(niche, NicheHelper.getNicheRouting());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Ниши сайта, дефолтная и слаги — то, что нужно `nichePath` и прочим хелперам `itube-specs/config`. */
|
|
28
|
+
public static getNicheRouting(): { niches: Niche[], defaultNiche: Niche, slugs?: NicheSlugs } {
|
|
29
|
+
const appConfig = useAppConfig();
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
niches: (appConfig.niches as Niche[] | undefined) || [],
|
|
33
|
+
defaultNiche: NicheHelper.getSiteDefaultNiche(),
|
|
34
|
+
slugs: appConfig.nicheSlugs as NicheSlugs | undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
17
37
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|